-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbuild.mill
More file actions
1604 lines (1489 loc) · 67.9 KB
/
Copy pathbuild.mill
File metadata and controls
1604 lines (1489 loc) · 67.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
//| mvnDeps:
//| - com.guardsquare:proguard-base:7.9.1
//| - com.lihaoyi::mill-contrib-buildinfo:$MILL_VERSION
//| - com.goyeau::mill-scalafix::0.6.0
//| bspScriptIgnore: [scripts/**, .scala-build/**]
// scripts/scalasemantic-mcp.scala is a scala-cli script (own `//> using dep`, own .bsp/scala-cli.json
// connection) — NOT a Mill module. Without bspScriptIgnore above, Mill's BSP support walks the
// workspace for loose .java/.scala/.kt files and wraps it in a synthetic single-file module using
// Mill's OWN build classpath, ignoring the script's using-directives entirely and breaking IDE
// compilation of it. .scala-build/** is scala-cli's own generated wrapper-source cache for that
// same script — Mill's scanner picks that up too if not excluded.
// DRAFT Mill build (Mill 1.1.7) — mirrors the sbt build catalogued in docs/MILL_MIGRATION.md.
// Uses SbtModule so the existing sbt dir layout (src/main/scala, src/test/scala) works unchanged.
// Search "TODO"/"RISK" for parts still needing external plugins or verification.
package build
import mill.*
import mill.scalalib.*
import mill.scalalib.publish.*
import mill.contrib.buildinfo.BuildInfo
import com.goyeau.mill.scalafix.ScalafixModule
// ---------------------------------------------------------------------------------------------
// Shared versions (were `lazy val`s in build.sbt)
// ---------------------------------------------------------------------------------------------
object V {
val scala3 = "3.8.4"
val scalameta = "4.17.0"
val upickle = "4.4.3"
val refined = "0.11.3"
val munit = "1.3.3"
val munitScalacheck = "1.3.0"
val slf4jNop = "1.7.36"
val presentationCompiler = "3.8.4"
val wartremover = "3.6.0" // must match the compiler-plugin artifact
val compatScala = Seq("2.13.16", "3.3.4")
}
def munitDeps = Seq(
mvn"org.scalameta::munit:${V.munit}",
mvn"org.scalameta::munit-scalacheck:${V.munitScalacheck}"
)
// ---------------------------------------------------------------------------------------------
// commonSettings — SemanticDB + wart rules + strict warnings (build.sbt `commonSettings`).
// RISK(wartremover): Mill has NO wartremover plugin. We add the wartremover *compiler plugin* via
// scalacPluginMvnDeps and pass its traverser flags as scalacOptions. The main/test split (strict
// warts as ERRORS in Compile, dropped in Test) is done by overriding scalacOptions in `object test`.
// ---------------------------------------------------------------------------------------------
// Shared scalafix wiring (com.goyeau::mill-scalafix, NOT scalafix-interfaces directly) — mixed into
// every first-party module (via `Common`, and standalone into `docs`) so `m.fix("--check")`/`m.fix()`
// work uniformly and modules can be discovered reflectively (see `checkScalafixFirstParty`).
trait ScalafixConfigured extends ScalafixModule {
def scalafixConfig = Task(Some(build.moduleDir / ".scalafix.conf"))
def scalafixIvyDeps = Seq(mvn"org.typelevel::typelevel-scalafix:0.5.0")
}
trait Common extends SbtModule with ScalafixConfigured with mill.scalalib.scalafmt.ScalafmtModule {
def id: String
def scalaVersion = V.scala3
// SemanticDB emission for dogfooding + scalafix. Mill's `semanticDbEnabled` only feeds the IDE
// `semanticDbData`/BSP target — the normal `compile` does NOT write *.semanticdb. sbt's
// `semanticdbEnabled` instead adds `-Xsemanticdb` to the compile so the files land in the class
// output; the dogfood tests scan for exactly those. So drive it via the compiler flag directly:
// Scala 3 writes them under <classes>/META-INF/semanticdb (out/<mod>/compile.dest/classes/...).
def semanticDbEnabled = true
// Modules doing Java interop (pc) turn warts OFF — build.sbt `.disablePlugins(WartRemover)`.
def useWartremover = true
// wartremover as a compiler plugin (replaces the sbt-wartremover plugin). `:::` = full Scala
// version cross, required for compiler plugins.
def scalacPluginMvnDeps =
super.scalacPluginMvnDeps() ++
(if (useWartremover) Seq(mvn"org.wartremover:::wartremover:${V.wartremover}") else Nil)
// Always-on wart WARNINGS (build.sbt `wartremoverWarnings ++=`).
def warnWarts = Seq(
"Var",
"MutableDataStructures",
"NonUnitStatements",
"Throw",
"Return",
"AsInstanceOf",
"IsInstanceOf",
"Null"
)
// Compile-only wart ERRORS (build.sbt `strictCompileWarts`).
def strictWarts = Seq(
"ArrayEquals",
"ArrayToString",
"EitherProjectionPartial",
"Enumeration",
"IterableOps",
"JavaNetURLConstructors",
"LeakingSealed",
"ListAppend",
"MapUnit",
"ObjectThrowable",
"OptionPartial",
"PartialFunctionApply",
"SeqApply",
"StringPlusAny",
"TripleQuestionMark",
"TryPartial",
"While"
)
def wartFlags(names: Seq[String], level: String): Seq[String] =
names.map(n => s"-P:wartremover:$level:org.wartremover.warts.$n")
def scalacOptions = super.scalacOptions() ++ Seq(
// Emit *.semanticdb during compile (the dogfood source), matching sbt's semanticdbEnabled.
// `-sourceroot` is REQUIRED: without it Scala 3 writes each file next to its source (polluting
// src/); with it they land under <classes>/META-INF/semanticdb/<relpath> inside out/.
"-Xsemanticdb",
"-sourceroot",
build.moduleDir.toString,
"-Werror",
"-Wunused:all",
"-Wconf:msg=.*unused.*:e"
) ++ (if (useWartremover)
wartFlags(warnWarts, "only-warn-traverser") ++ wartFlags(strictWarts, "traverser")
else Nil)
override def compileClasspath = Task {
val cp = super.compileClasspath()
val root = build.moduleDir
val baseDir = this.moduleDir
val scalaVersion = this.scalaVersion()
val classesDir = build.moduleDir / "out" / id / "compile.dest" / "classes"
val module = ScalaSemanticClasspathMetadata.Module(
id = id,
baseDir = baseDir,
scalaVersion = scalaVersion,
classpath = classesDir +: cp.map(_.path)
)
val moduleContent = ScalaSemanticClasspathMetadata.render(root, Seq(module))
val destFile = Task.dest / s"classpath-mill-$id.json"
os.write.over(destFile, moduleContent)
val finalDir = root / ".scala-semantic"
os.proc("mkdir", "-p", finalDir.toString).call(cwd = root)
os.proc("cp", destFile.toString, (finalDir / s"classpath-mill-$id.json").toString)
.call(cwd = root)
cp
}
// One-off rewrite to native Scala 3 style (no `then`/`do`, brace->indentation). NOT wired into
// scalacOptions: -rewrite mutates source files in place, so it must be run manually, its diff
// reviewed, and committed — never on every normal compile/CI run.
def rewriteToScala3Style() = Task.Command {
// dotc rejects "-new-syntax -indent" combined in one -rewrite pass ("illegal combination of
// -rewrite targets") — run them as two sequential passes, each rewriting files in place.
Task.log.info(s"Rewriting ${moduleDir} sources to Scala 3 style (-new-syntax, then -indent)...")
val compilerCp =
scalaCompilerClasspath().map(_.path.toString).mkString(java.io.File.pathSeparator)
val cp = compileClasspath().map(_.path.toString).mkString(java.io.File.pathSeparator)
val srcs = allSourceFiles().map(_.path.toString)
def runPass(target: String, dest: os.Path): Int = {
os.makeDir.all(dest)
os.proc(
"java",
"-cp",
compilerCp,
"dotty.tools.dotc.Main",
"-rewrite",
target,
"-classpath",
cp,
"-d",
dest.toString,
srcs
).call(cwd = moduleDir, stdout = os.Inherit, stderr = os.Inherit, check = false)
.exitCode
}
val rc1 = runPass("-new-syntax", Task.dest / "pass1")
if (rc1 != 0)
sys.error(s"rewriteToScala3Style (-new-syntax) failed in ${moduleDir} (exit $rc1)")
val rc2 = runPass("-indent", Task.dest / "pass2")
if (rc2 != 0) sys.error(s"rewriteToScala3Style (-indent) failed in ${moduleDir} (exit $rc2)")
}
trait CommonTests
extends SbtTests
with TestModule.Munit
with ScalafixConfigured
with mill.scalalib.scalafmt.ScalafmtModule {
def mvnDeps = munitDeps
// Dogfood suites call SemanticIndex.fromProject(".") and scan for *.semanticdb from cwd. Mill
// forks tests in the module dir by default, but the emitted SemanticDB (and every module's) lives
// under the repo-root `out/`. Run from repo root so "." sees all of it (sbt ran unforked from root).
// Mill sandboxes each test run in a temp cwd, which would override forkWorkingDir — turn it off.
def testSandboxWorkingDir = false
def forkWorkingDir = build.moduleDir
// Test drops the strict wart errors (build.sbt `Test/wartremoverErrors --= strictCompileWarts`).
def strictTraversers =
Common.this.strictWarts.map(w => s"-P:wartremover:traverser:org.wartremover.warts.$w").toSet
def scalacOptions = Common.this.scalacOptions().filterNot(strictTraversers.contains)
}
}
// ---------------------------------------------------------------------------------------------
// core — load + index SemanticDB. No deps.
// ---------------------------------------------------------------------------------------------
object core extends Common with PublishModule {
def id = "core"
def artifactName = "scalasemantic-core"
def publishVersion = build.publishVersion()
def pomSettings = commonPom("scalasemantic-core")
def mvnDeps = Seq(
mvn"org.scalameta::scalameta:${V.scalameta}",
mvn"org.scalameta::semanticdb-shared:${V.scalameta}"
)
object test extends CommonTests
}
// ---------------------------------------------------------------------------------------------
// pc — presentation-compiler backend. dependsOn core. Tests FORKED (Mill forks by default).
// Warts OFF (Java interop) — build.sbt `.disablePlugins(WartRemover)`.
// ---------------------------------------------------------------------------------------------
object pc extends Common with PublishModule {
def id = "pc"
def artifactName = "scalasemantic-pc"
def publishVersion = build.publishVersion()
def pomSettings = commonPom("scalasemantic-pc")
def moduleDeps = Seq(core)
override def useWartremover = false // Java interop — build.sbt `.disablePlugins(WartRemover)`
def mvnDeps = Seq(
mvn"org.scala-lang::scala3-presentation-compiler:${V.presentationCompiler}",
mvn"org.slf4j:slf4j-nop:${V.slf4jNop}"
)
object test extends CommonTests
}
// ---------------------------------------------------------------------------------------------
// analysis — query engine + models. dependsOn core, pc. Unmanaged stainless jar from lib/.
// ---------------------------------------------------------------------------------------------
object analysis extends Common with PublishModule {
def id = "analysis"
def artifactName = "scalasemantic-analysis"
def publishVersion = build.publishVersion()
def pomSettings = commonPom("scalasemantic-analysis")
def moduleDeps = Seq(core, pc)
def mvnDeps = Seq(
mvn"com.lihaoyi::upickle:${V.upickle}",
mvn"eu.timepit::refined:${V.refined}"
)
// build.sbt: stainless-library.jar is an UNMANAGED dep auto-picked from analysis/lib/.
// Mill 1.x: a build-tracked file read in a task must be declared via Task.Source.
def stainlessJar = Task.Source(moduleDir / "lib" / "stainless-library.jar")
def unmanagedClasspath = Task { Seq(stainlessJar()) }
object test extends CommonTests {
def unmanagedClasspath = Task { Seq(stainlessJar()) }
}
// build.sbt `stainlessVerify` task — shells scripts/stainless-verify.sh.
def stainlessVerify() = Task.Command {
val script = build.moduleDir / "scripts" / "stainless-verify.sh"
val rc = os.proc("bash", script.toString).call(cwd = build.moduleDir, check = false)
if (rc.exitCode != 0) sys.error(s"stainlessVerify failed (exit ${rc.exitCode})")
}
}
// ---------------------------------------------------------------------------------------------
// launcher — jar-side setup/config dispatcher used by the shell launcher and mcp entrypoint.
// ---------------------------------------------------------------------------------------------
object launcher extends Common with PublishModule {
def id = "launcher"
def artifactName = "scalasemantic-launcher"
def publishVersion = build.publishVersion()
def pomSettings = commonPom("scalasemantic-launcher")
}
// ---------------------------------------------------------------------------------------------
// mcp — stdio JSON-RPC server + entrypoint. test->test dep on analysis. Version source + assembly.
// ---------------------------------------------------------------------------------------------
object mcp extends Common with PublishModule with BuildInfo {
def id = "mcp"
def artifactName = "scalasemantic-mcp"
def publishVersion = build.publishVersion()
def pomSettings = commonPom("scalasemantic-mcp")
def moduleDeps = Seq(analysis, build.launcher)
def mvnDeps = Seq(mvn"com.lihaoyi::upickle:${V.upickle}")
// build.sbt: two @main — pin the entrypoint for run + assembly manifest.
def mainClass = Some("com.github.mercurievv.scalasemantic.mcpServer")
// Replaces sbt-buildinfo (build.sbt BuildInfoPlugin): mill-contrib BuildInfo generates
// `object BuildInfo { val version = "<ver>" }` in the package below so the server can surface the
// git-derived version in `serverInfo.version` at runtime instead of a hardcoded literal.
def buildInfoPackageName = "com.github.mercurievv.scalasemantic.buildinfo"
// Bake the members as compiled `val`s in the object (sbt-buildinfo parity) instead of the Mill
// default (values read from a generated resource file at runtime) — so `BuildInfo.version` resolves
// even from the ProGuard-shrunk fat jar, where the resource-loading path is fragile.
def buildInfoStaticCompiled = true
def buildInfoMembers = Task {
Seq(BuildInfo.Value("version", build.publishVersion()))
}
// BuildInfo and MavenModule both define `resources`; pick BuildInfo's, which chains through the
// linearization back to MavenModule's (so the generated buildinfo resource is ADDED, not replaced).
override def resources = super[BuildInfo].resources
// build.sbt `mcp % "compile->compile;test->test"` — mcp tests depend on analysis tests.
// Also depends on `docExamples` (compile-order only, not used at runtime) so its SemanticDB is
// guaranteed emitted before DocsToolExamplesGoldenSuite/DocsEnrichingExamplesGoldenSuite scan the
// whole-project index for it — mirrors why analysis.test is a dep for the `fixtures` package.
object test extends CommonTests {
def moduleDeps = super.moduleDeps ++ Seq(analysis.test, docExamples)
}
// assembly / fat jar (sbt-assembly). Port of the merge strategy.
// sbt discarded module-info.class + jar signatures and used MergeStrategy.first for the
// scala/tools/asm ↔ scala-asm collision (scala3-pc drags the 2.13 compiler) and duplicate
// .properties. Mill's assembly ALREADY keeps the first entry for any duplicate path, so those two
// are the default — do NOT add Append rules (Append concatenates .class bytecode, corrupting it).
// Only the discards need explicit rules.
def assemblyRules = super.assemblyRules ++ Seq(
Assembly.Rule.ExcludePattern(".*module-info\\.class"),
Assembly.Rule.ExcludePattern("META-INF/.*\\.(SF|DSA|RSA)")
)
// build.sbt `mcpLauncher` — dev launch script off this build's classpath.
def mcpLauncher = Task {
val cp = runClasspath().map(_.path).mkString(java.io.File.pathSeparator)
val script = Task.dest / "scalasemantic-mcp"
os.write(
script,
s"""|#!/usr/bin/env sh
|exec java -cp "$cp" ${mainClass().get} "$$@"
|""".stripMargin
)
os.perms.set(script, "rwxr-xr-x")
PathRef(script)
}
// build.sbt `proguard` — shrink the assembly fat jar in place. proguard-base is on the meta-build
// classpath via the `//| mvnDeps` header, so `_root_.proguard.*` is referenced directly (the
// `_root_` prefix disambiguates the library package from this task named `proguard`).
// -keep set ported verbatim from build.sbt: keep the entrypoint + all first-party classes, all
// Scala module singletons (MODULE$), the upickle reader/writer subclasses, and the reflection-
// reached namespaces (scala/dotty/scalameta/pc/upickle/protobuf/…) so nothing is stripped that
// the presentation compiler or upickle derivation looks up by name at runtime.
def proguard: T[PathRef] = Task {
val inputJar = assembly().path
val outputJar = Task.dest / "scalasemantic-mcp-shrunk.jar"
val args = Array(
"-injars",
inputJar.toString,
"-outjars",
outputJar.toString,
"-libraryjars",
s"${System.getProperty("java.home")}/jmods/java.base.jmod(!**.jar;!module-info.class)",
"-dontobfuscate",
"-dontoptimize",
"-ignorewarnings",
"-dontnote",
"-dontwarn",
"-keepattributes",
"Exceptions,InnerClasses,Signature,Deprecated,SourceFile,LineNumberTable,*Annotation*,EnclosingMethod",
"-keep",
"public class com.github.mercurievv.scalasemantic.mcpServer { public static void main(java.lang.String[]); }",
"-keep",
"class com.github.mercurievv.scalasemantic.mcpServer$ { *; }",
"-keep",
"class com.github.mercurievv.scalasemantic.** { *; }",
"-keepclassmembers",
"class * { public static ** MODULE$; }",
"-keep",
"class scala.Dynamic { *; }",
"-keep",
"class * extends upickle.core.Reader { *; }",
"-keep",
"class * extends upickle.core.Writer { *; }",
"-keepclassmembers",
"class scala.** { *; }",
"-keepclassmembers",
"class dotty.tools.** { *; }",
"-keepclassmembers",
"class org.scalameta.** { *; }",
"-keep",
"class scala.meta.internal.metals.** { *; }",
"-keep",
"class dotty.tools.pc.** { *; }",
"-keepclassmembers",
"class upickle.** { *; }",
"-keepclassmembers",
"class ujson.** { *; }",
"-keepclassmembers",
"class upack.** { *; }",
"-keepclassmembers",
"class com.google.protobuf.** { *; }",
"-keepclassmembers",
"class fastparse.** { *; }",
"-keepclassmembers",
"class geny.** { *; }",
"-keepclassmembers",
"class sourcecode.** { *; }",
"-keep",
"class xsbti.** { *; }",
"-keep",
"class org.eclipse.lsp4j.** { *; }"
)
val configuration = new _root_.proguard.Configuration()
val parser = new _root_.proguard.ConfigurationParser(args, System.getProperties)
parser.parse(configuration)
new _root_.proguard.ProGuard(configuration).execute()
Task.log.info(s"ProGuard shrunk jar: $outputJar")
PathRef(outputJar)
}
// build.sbt `testShrunk` — run the three PC/analysis/mcp suites forked against the SHRUNK jar to
// prove ProGuard kept everything the runtime reflectively reaches. Mirrors the sbt classpath
// surgery: strip mcp's whole runtime classpath (everything already inside the fat jar) from the
// test classpath, then add the shrunk jar — so first-party + bundled classes resolve only from the
// shrunk artifact, while test-only deps (munit/junit) and the test classes survive. Forked from the
// repo root because AnalyzerPcSuite/McpSuite dogfood `SemanticIndex.fromProject(".")`.
def testShrunk() = Task.Command {
val bundled = runClasspath().map(_.path.toString).toSet
val testCp = (pc.test.runClasspath() ++ test.runClasspath()).map(_.path.toString).distinct
val shrunk = proguard().path.toString
val filtered = testCp.filterNot(bundled.contains) :+ shrunk
val suites = Seq(
"com.github.mercurievv.scalasemantic.pc.PresentationCompilerBackendSuite",
"com.github.mercurievv.scalasemantic.analysis.AnalyzerPcSuite",
"com.github.mercurievv.scalasemantic.mcp.McpSuite"
)
val cp = filtered.mkString(java.io.File.pathSeparator)
Task.log.info(s"Running suites against shrunk jar $shrunk ...")
val res = os
.proc("java", "-cp", cp, "org.junit.runner.JUnitCore", suites)
.call(cwd = build.moduleDir, stdout = os.Inherit, stderr = os.Inherit, check = false)
if (res.exitCode != 0) sys.error(s"testShrunk failed (exit ${res.exitCode})")
}
}
// ---------------------------------------------------------------------------------------------
// doc-examples — fixture sources for real tool-examples documentation. Warts OFF, unpublished.
// SemanticDB emitted for indexing, no runtime dependency.
// ---------------------------------------------------------------------------------------------
object docExamples extends Common {
def id = "doc-examples"
override def useWartremover = false
// Relax strict warnings to allow illustrative unused code in fixtures. -Wunused itself stays
// (as a warning) because the OrganizeImports/RemoveUnused scalafix rules require it; only the
// -Werror escalation and the unused->error -Wconf rule are dropped.
override def scalacOptions =
super.scalacOptions().filterNot(o => o == "-Werror" || o.startsWith("-Wconf:msg=.*unused"))
}
// compat-fixtures — cross-compiled 2.13.16 + 3.3.4, emits golden SemanticDB. Warts OFF, unpublished.
// ---------------------------------------------------------------------------------------------
object compatFixtures extends Cross[CompatModule](V.compatScala)
trait CompatModule
extends SbtModule
with Cross.Module[String]
with ScalafixConfigured
with mill.scalalib.scalafmt.ScalafmtModule {
def scalaVersion = crossValue
def moduleDir = build.moduleDir / "compat-fixtures"
// sbt's crossScalaVersions convention auto-adds a `src/main/scala-<binVer>` sibling directory to
// the source set; Mill's SbtModule doesn't, so the version-specific fixtures (BasicClasses.scala
// etc. under scala-2.13/ or scala-3/) were being silently skipped — `allSourceFiles` came back
// empty and nothing got compiled at all.
def sources = Task.Sources(
moduleDir / "src" / "main" / "scala",
moduleDir / "src" / "main" / "java",
moduleDir / "src" / "main" / (if (crossValue.startsWith("2.")) "scala-2.13" else "scala-3")
)
// Mill's `semanticDbEnabled` only feeds the IDE/BSP `semanticDbData` target, NOT `compile` (same
// gap `Common` works around — see its comment above). compatGolden needs actual compiled
// *.semanticdb next to the classes, so drive emission by compiler flag/plugin per cross version:
// Scala 3 supports `-Xsemanticdb` natively; Scala 2.13 needs the semanticdb-scalac compiler plugin.
def scalacPluginMvnDeps =
super.scalacPluginMvnDeps() ++
(if (crossValue.startsWith("2.")) Seq(mvn"org.scalameta:::semanticdb-scalac:${V.scalameta}")
else Nil)
def scalacOptions = super.scalacOptions() ++
(if (crossValue.startsWith("2."))
// -Wunused: required by the OrganizeImports/RemoveUnused scalafix rules (see ScalafixConfigured).
Seq("-Yrangepos", s"-P:semanticdb:sourceroot:${build.moduleDir}", "-Wunused")
else Seq("-Xsemanticdb", "-sourceroot", build.moduleDir.toString, "-Wunused:all"))
// build.sbt `compatGolden` — compile then copy emitted *.semanticdb into versioned golden resources.
def compatGolden() = Task.Command {
val binVer =
if (crossValue.startsWith("2.")) crossValue.split('.').take(2).mkString(".") else "3"
// Compiled classes dir carries META-INF/semanticdb alongside the .class files (same layout the
// dogfood tests scan for in core/pc/analysis/mcp — see `Common.scalacOptions` above).
val src = compile().classes.path / "META-INF" / "semanticdb"
val dst =
build.moduleDir / "analysis" / "src" / "test" / "resources" / "compat" / s"scala-$binVer"
os.remove.all(dst)
if (os.exists(src)) os.copy(src, dst, createFolders = true)
Task.log.info(s"compat golden: $src -> $dst")
}
}
// ---------------------------------------------------------------------------------------------
// docs — mdoc *library* (no sbt-mdoc for sbt 2.0). Standalone, forked run, same Scala version as
// the rest of the project (see the scala3-compiler override below — mdoc's own transitive
// compiler dep would otherwise pin fences to Scala 3.3 LTS regardless of `scalaVersion` here).
// ToolRunner calls `mcp` in-process (a real moduleDep below) instead of shelling to the assembly
// jar — same code path the MCP server itself runs, just without a JVM spin-up per fence. The
// packaged jar's own correctness (manifest, entrypoint, shading) is covered by mcp's own tests,
// not by this doc build.
// ---------------------------------------------------------------------------------------------
object docs extends SbtModule with ScalafixConfigured with mill.scalalib.scalafmt.ScalafmtModule {
def scalaVersion = V.scala3
def moduleDir = build.moduleDir / "mdoc-docs"
def moduleDeps = Seq(mcp)
// OrganizeImports/RemoveUnused (scalafix) require -Wunused enabled to have anything to check.
def scalacOptions = super.scalacOptions() ++ Seq("-Wunused:all")
def mvnDeps = Seq(
mvn"org.scalameta::mdoc:2.9.0",
mvn"com.lihaoyi::os-lib:0.11.3",
mvn"com.lihaoyi::upickle:4.4.3",
// mdoc:2.9.0 transitively pulls scala3-compiler_3:3.3.7 (its LTS build). Force it to match
// this module's own scalaVersion so mdoc's fence compiler and the module's own classpath
// agree — without this, coursier leaves both scala3-library versions on the classpath and
// dotc errors with "package scala contains object and package with same name: caps".
mvn"org.scala-lang::scala3-compiler:${V.scala3}",
// mdoc_3:2.9.0's OWN markdown-fence parser is implemented against scalameta's classic 2.13
// build (`scalameta_2.13:4.16.0`, not the `_3` cross-build `mcp` pulls in at `V.scalameta`) —
// a real, separate artifact coursier won't version-unify on its own. Left alone, both
// `trees_2.13:4.16.0` and `trees_3:${V.scalameta}` end up on the runtime classpath defining
// overlapping `scala.meta.internal.trees.*` classes at different binary revisions, and
// whichever the classloader hits first for a given class wins — surfacing as
// `NoSuchMethodError` deep in mdoc's parser (e.g. `XtensionOriginTree.hasComments`). Forcing
// the 2.13 artifacts up to the same release coursier already picks for `trees2_2.13`/
// `common2_2.13`/`io_2.13` (visible as "4.16.0 -> 4.17.0" in `docs.showMvnDepsTree`) unifies
// every scalameta jar on one version, whichever build gets loaded first.
mvn"org.scalameta:scalameta_2.13:${V.scalameta}",
mvn"org.scalameta:parsers_2.13:${V.scalameta}",
mvn"org.scalameta:trees_2.13:${V.scalameta}",
mvn"org.scalameta:common_2.13:${V.scalameta}"
)
// build.sbt passes -Dscalasemantic.docs.version=<latestReleaseVersion> and forks from repo root.
def forkArgs = Task {
Seq(
s"-Dscalasemantic.docs.version=${latestReleaseVersion()}",
s"-Dscalasemantic.docs.indexDir=${docExamples.compile().classes.path}",
s"-Dscalasemantic.docs.structureIndexDirs=${Seq(
core.compile().classes.path,
analysis.compile().classes.path,
mcp.compile().classes.path
).mkString(java.io.File.pathSeparator)}"
)
}
def forkWorkingDir = build.moduleDir
}
// ---------------------------------------------------------------------------------------------
// Root-level tasks / helpers
// ---------------------------------------------------------------------------------------------
// build.sbt: version derived from git tags (sbt-dynver).
// TODO(RISK): replace with de.tobiasroeser::mill-vcs-version VcsVersion for full dynver parity
// (dirty/commit-distance suffixes). This plain fallback only reads the highest tag.
def publishVersion: T[String] = Task { latestReleaseVersion() }
// build.sbt `latestReleaseVersion` — highest `v*` git tag, stripped, else "x.y.z".
def latestReleaseVersion: T[String] = Task {
scala.util
.Try {
os.proc("git", "tag", "--list", "v*")
.call()
.out
.lines()
.map(_.trim.stripPrefix("v"))
.filter(_.matches("""\d+\.\d+\.\d+"""))
.maxBy { v =>
val p = v.split('.').map(_.toInt); (p(0), p(1), p(2))
}
}
.getOrElse("x.y.z")
}
// Shared POM metadata (build.sbt ThisBuild publish settings).
def commonPom(name: String) = PomSettings(
description = name,
organization = "io.github.mercurievv",
url = "https://github.com/mercurievv/ScalaSemantic",
licenses = Seq(License.MIT),
versionControl = VersionControl.github("mercurievv", "ScalaSemantic"),
developers = Seq(Developer("mercurievv", "Viktors Kalinins", "https://github.com/mercurievv"))
)
// Rewrite every first-party module's sources to native Scala 3 style in one shot. Run manually,
// review the diff, commit — never wired into prePush/CI (see `Common.rewriteToScala3Style`).
def rewriteToScala3StyleAll() = Task.Command {
Task.traverse(Seq(core, pc, analysis, mcp))(_.rewriteToScala3Style())()
}
// build.sbt `compatGoldenAll` alias — regenerate golden for every compat Scala version.
def compatGoldenAll() = Task.Command {
Task.traverse(V.compatScala.map(v => compatFixtures(v).compatGolden()))(identity)()
}
// Formatting is APPLIED by the pre-commit hook, so this only VERIFIES it. Both this and
// `checkScalafixFirstParty` below discover every module reflectively (via
// `build.moduleInternal.modules`) instead of hardcoding the list, so new modules (and their `test`
// submodules, and compatFixtures' cross-Scala-version instances) are picked up automatically — no
// exclusions. Also invoked directly from the CI `Check formatting` step.
def checkFormatFirstParty() = Task.Command {
mill.scalalib.scalafmt.ScalafmtModule.checkFormatAll(
mill.util.Tasks(
build.moduleInternal.modules.collect { case m: mill.scalalib.scalafmt.ScalafmtModule =>
m.sources
}
)
)()
}
// scalafix via com.goyeau::mill-scalafix (see `ScalafixConfigured`), NOT scalafix-interfaces
// directly — same reflective-discovery pattern as `checkFormatFirstParty` above.
def checkScalafixFirstParty() = Task.Command {
Task.traverse(
build.moduleInternal.modules.collect { case m: ScalafixModule => m }
)(_.fix("--check"))()
}
def smokeTest() = Task.Command {
mcp.assembly()
val scalaCliScript = build.moduleDir / "scripts" / "smoke-test.sh"
val scalaCliRes = os
.proc("sh", scalaCliScript.toString)
.call(
cwd = build.moduleDir,
stdout = os.Inherit,
stderr = os.Inherit,
check = false
)
if (scalaCliRes.exitCode != 0) {
sys.error(s"E2E Smoke Test (scala-cli fixture) failed with exit status ${scalaCliRes.exitCode}")
}
val millScript = build.moduleDir / "scripts" / "smoke-test-mill.sc"
val millRes = os
.proc("scala-cli", "run", "--server=false", millScript.toString)
.call(
cwd = build.moduleDir,
stdout = os.Inherit,
stderr = os.Inherit,
check = false
)
if (millRes.exitCode != 0) {
sys.error(s"E2E Smoke Test (Mill project) failed with exit status ${millRes.exitCode}")
}
val scriptsScript = build.moduleDir / "scripts" / "smoke-test-scripts.sc"
val scriptsRes = os
.proc("scala-cli", "run", "--server=false", scriptsScript.toString)
.call(
cwd = build.moduleDir,
stdout = os.Inherit,
stderr = os.Inherit,
check = false
)
if (scriptsRes.exitCode != 0) {
sys.error(
s"E2E Smoke Test (standalone scripts/ scala-cli scripts) failed with exit status ${scriptsRes.exitCode}"
)
}
}
def prePush() = Task.Command {
checkFormatFirstParty()()
checkScalafixFirstParty()()
compatGoldenAll()()
Task.traverse(
build.moduleInternal.modules.collect { case m: TestModule => m }
)(_.testForked())()
analysis.stainlessVerify()()
smokeTest()()
Task.log.info("Checking unit and E2E regression consistency...")
val res = os
.proc(
"scala-cli",
"--server=false",
"scripts/generate-regressions.scala",
"--",
"--check",
"--categories",
"unit",
"e2e"
)
.call(cwd = build.moduleDir, stdout = os.Inherit, stderr = os.Inherit, check = false)
if (res.exitCode != 0) {
sys.error(s"Regression check failed with exit status ${res.exitCode}")
}
}
// build.mill itself lives in its own evaluator context (meta-level 1), unreachable from a level-0
// Task.Command. DO NOT shell out to `./mill --meta-level 1 ...` from inside a Task.Command to
// check it: Mill serializes every command through one daemon per project, so a Task.Command
// blocked on that nested invocation's exit, while the invocation itself waits for the very daemon
// that's blocked on it, deadlocks — confirmed in practice (hung 15+ minutes, killed manually).
// To scalafmt-check build.mill, run scripts/check-build-mill-fmt.sh from a shell (locally, or as
// its own CI step) — never wrap it in a Mill task.
// ---------------------------------------------------------------------------------------------
// Relocated from project/CorpusFetch.scala (sbt meta-build class) — no sbt-specific API used
// there beyond File/IO/Logger, so ported onto os-lib (already on the Mill build classpath).
// ---------------------------------------------------------------------------------------------
object CorpusFetch {
final case class CorpusPin(ref: String, sha256: String)
final case class VendorCorpus(
id: String,
owner: String,
repo: String,
pin: CorpusPin,
subtree: String,
targetName: String
) {
def archiveUrl: String = s"https://github.com/$owner/$repo/archive/${pin.ref}.tar.gz"
def archiveRoot: String = s"$repo-${pin.ref.stripPrefix("v")}"
}
val vendorCorpora: Seq[VendorCorpus] = Seq(
VendorCorpus(
id = "scalameta-semanticdb-integration",
owner = "scalameta",
repo = "scalameta",
pin =
CorpusPin("v4.13.9", "4617568610271364a83bba0c61e9ffc8fe77457966d1a95abd7ad7540b322146"),
subtree = "tests-semanticdb/src/test/resources/example",
targetName = "scala-2.13"
),
VendorCorpus(
id = "scala3-semanticdb-expect",
owner = "scala",
repo = "scala3",
pin = CorpusPin("3.8.4", "c0b742a933f12c4fce53554bb067c2df176f7b22c3c16c7cd72b12d64cff4d03"),
subtree = "tests/semanticdb/expect",
targetName = "scala-3"
)
)
def fetch(vendorDir: os.Path, log: String => Unit): Seq[os.Path] = {
val archiveDir = vendorDir / "_archives"
os.makeDir.all(archiveDir)
vendorCorpora.map { corpus =>
val archive = archiveDir / s"${corpus.id}-${corpus.pin.ref}.tar.gz"
if (os.exists(archive)) {
verifySha256(archive, corpus.pin.sha256)
log(s"Using cached checksum-verified archive: $archive")
} else {
downloadArchive(corpus.archiveUrl, archive, log)
verifySha256(archive, corpus.pin.sha256)
}
extractCorpus(archive, corpus, vendorDir, log)
}
}
private def sha256Hex(file: os.Path): String = {
val digest = java.security.MessageDigest.getInstance("SHA-256")
digest.update(os.read.bytes(file))
digest.digest().map("%02x".format(_)).mkString
}
private def verifySha256(file: os.Path, expected: String): Unit = {
val actual = sha256Hex(file)
if (!actual.equalsIgnoreCase(expected))
sys.error(
s"Checksum mismatch for ${file.last}: expected $expected but found $actual. " +
"Delete the cached archive and retry only after confirming the pinned upstream ref."
)
}
private def downloadArchive(url: String, target: os.Path, log: String => Unit): Unit = {
val tmp = (target / os.up) / s".${target.last}.download"
os.remove(tmp, checkExists = false)
log(s"Downloading $url")
val connection = new java.net.URI(url).toURL.openConnection()
connection.setConnectTimeout(15000)
connection.setReadTimeout(120000)
val in = new java.io.BufferedInputStream(connection.getInputStream)
try {
val out = java.nio.file.Files.newOutputStream(tmp.toNIO)
try in.transferTo(out)
finally out.close()
} finally in.close()
os.move(tmp, target)
}
private def extractCorpus(
archive: os.Path,
corpus: VendorCorpus,
vendorDir: os.Path,
log: String => Unit
): os.Path = {
val dest = vendorDir / corpus.targetName
val marker = dest / ".corpus-fetch.sha256"
if (os.exists(dest) && os.exists(marker) && os.read(marker).trim == corpus.pin.sha256) {
log(s"${corpus.targetName} corpus is up to date: $dest")
dest
} else {
val tmp = vendorDir / s".${corpus.targetName}.tmp"
os.remove.all(tmp)
os.makeDir.all(tmp)
val stripComponents = corpus.subtree.split('/').length + 1
val sourcePath = s"${corpus.archiveRoot}/${corpus.subtree}"
val rc = os
.proc(
"tar",
"-xzf",
archive.toString,
"-C",
tmp.toString,
s"--strip-components=$stripComponents",
sourcePath
)
.call(check = false)
.exitCode
if (rc != 0) sys.error(s"Failed to extract $sourcePath from ${archive.last} (exit $rc)")
os.remove.all(dest)
os.move(tmp, dest)
os.write.over(marker, corpus.pin.sha256 + "\n")
log(s"Extracted ${corpus.id} (${corpus.pin.ref}) to $dest")
dest
}
}
}
// build.sbt `corpusFetch` (root taskKey) — fetch checksum-verified upstream SemanticDB compat
// corpora into the repo-root `target/vendor-corpus` path THIRD_PARTY.md documents as the public
// contract for where the corpora land. Kept as a Command (writes outside Task.dest, which plain
// Tasks may not do) purely for that public/manual-inspection path; `corpus.*` below does its own
// independent fetch (see comment on `CorpusModule.sources`) so `corpus.*.compile` doesn't require
// this Command to have been run first.
def corpusFetch() = Task.Command {
CorpusFetch.fetch(build.moduleDir / "target" / "vendor-corpus", msg => Task.log.info(msg))
}
// ---------------------------------------------------------------------------------------------
// corpus — SemanticDB-emitting compile targets over the vendored compat corpora (whole-corpus,
// distinct from the small hand-authored `compatFixtures` above). #200.
// ---------------------------------------------------------------------------------------------
object CorpusExcludes {
// Systematic, not-meant-for-this-target-version patterns (checked by filename suffix):
// - scala-3 corpus: `*.expect.scala` are semanticdb-annotated *golden output* snippets (e.g.
// `object X/*<-objects::X.*/`) — not valid Scala syntax, never sources.
// - scala-2.13 corpus: `*_2.12.scala` are the scalameta corpus's Scala-2.12-only sibling
// fixtures of the `*_2.13.scala` files we target; same package + type names, so compiling
// both together clashes. Keep only the `_2.13` variant.
def suffixExcluded(binVer: String, fileName: String): Boolean =
if (binVer.startsWith("2.")) fileName.endsWith("_2.12.scala")
else fileName.endsWith(".expect.scala")
// Per-file exclude-list for individual hostile inputs found by trial compile (macro-annotation
// fixtures needing compiler plugins we don't wire, intentionally non-compiling test inputs,
// etc). One line per file, filename only — the single place to edit when a corpus refresh
// introduces a new hostile input.
val fileExcludes: Set[String] = Set(
"MacroAnnotations.scala", // needs macro-paradise-style annotation macro plugin, not wired here
"InfoMacro.scala", // whitebox macro impl module, not meant to compile standalone
"InfoMacroTest.scala", // depends on InfoMacro.scala's macro being compiled+loaded first
// Java-interop test inputs referencing a `com.javacp.*` Java source tree the corpus subtree
// doesn't include (only the `.scala` half of these paired java/scala compiler tests was
// fetched) — `javacp` is unresolvable without vendoring the companion .java sources too.
"Annotations.scala",
"JavaStaticVar.scala",
"MetacJava.scala",
// Scala-2-macro-signature test input (`def m[TT]: Int = macro ???`) — Scala 3 has no such
// legacy macro form and rejects it outright, not something an exclude-list-adjacent flag fixes.
"semanticdb-Flags.scala",
// These corpus fixtures embed the expected-symbol annotation directly as a block comment
// between a string-interpolator prefix and its string literal (`s/*=>...*/"..."`), which
// breaks 2.13's lexer (interpolator id + string must be lexically adjacent) — an artifact of
// the annotation format, not a real Scala restriction the analyzer needs to handle.
"ImplicitConversion_2.13.scala",
"Issue2882.scala"
)
def isExcluded(binVer: String, fileName: String): Boolean =
suffixExcluded(binVer, fileName) || fileExcludes.contains(fileName)
}
object corpus extends Cross[CorpusModule](V.compatScala)
trait CorpusModule extends SbtModule with Cross.Module[String] {
def scalaVersion = crossValue
// Scratch moduleDir — actual sources are fetched into this task's own dest, not a
// src/main/scala layout (see `sources` below).
def moduleDir = build.moduleDir / "target" / "corpus-module"
private def binVer =
if (crossValue.startsWith("2.")) crossValue.split('.').take(2).mkString(".") else "3"
// Mill sandboxes plain Tasks to only write within their own `Task.dest`, and PathRef of a
// directory outside a task's own dest/declared Task.Source trips the read-sandbox too — so
// this task fetches the corpora itself, into its own dest, making `corpus.*.compile` depend on
// (and transparently trigger) the fetch without needing `corpusFetch` run first. `CorpusFetch.
// fetch` is checksum-cache-idempotent, so this duplicates the download once per Mill `out/`
// cache, not per invocation.
def sources = Task {
CorpusFetch.fetch(Task.dest, msg => Task.log.info(msg))
Seq(PathRef(Task.dest / s"scala-$binVer"))
}
// Filters out systematic + per-file hostile inputs (see CorpusExcludes) so a handful of
// non-compiling/wrong-version files don't block emitting SemanticDB for the rest of the corpus.
override def allSourceFiles = Task {
super.allSourceFiles().filterNot(p => CorpusExcludes.isExcluded(binVer, p.path.last))
}
def scalacPluginMvnDeps =
super.scalacPluginMvnDeps() ++
(if (crossValue.startsWith("2.")) Seq(mvn"org.scalameta:::semanticdb-scalac:${V.scalameta}")
else Nil)
// sourceroot must be a directory that actually exists on disk (semanticdb-scalac calls
// `.toRealPath()` on it) — this module's own `moduleDir` (target/corpus-module) is a nonexistent
// placeholder (see comment above), so use the repo root instead, same as `compatFixtures`.
def scalacOptions = super.scalacOptions() ++
(if (crossValue.startsWith("2."))
Seq("-Yrangepos", s"-P:semanticdb:sourceroot:${build.moduleDir}")
else Seq("-Xsemanticdb", "-sourceroot", build.moduleDir.toString))
// Compile tolerates per-file failures where possible; a hostile input outside the exclude-lists
// still fails the whole compile (single Scala compiler invocation) — add it to `fileExcludes`.
def corpusGolden() = Task.Command {
val src = compile().classes.path / "META-INF" / "semanticdb"
val dst = build.moduleDir / "target" / "corpus" / s"scala-$binVer"
os.remove.all(dst)
if (os.exists(src)) os.copy(src, dst, createFolders = true)
Task.log.info(s"corpus golden: $src -> $dst")
}
}
// build.sbt `corpusGoldenAll` alias — regenerate whole-corpus SemanticDB for every compat Scala
// version. Each `corpus.*.compile` triggers its own fetch transparently via `sources` above.
def corpusGoldenAll() = Task.Command {
Task.traverse(V.compatScala.map(v => corpus(v).corpusGolden()))(identity)()
}
// ---------------------------------------------------------------------------------------------
// Relocated from project/ScalaSemanticConfigMerger.scala (sbt meta-build class) — pure-String
// rendering/merging logic, unchanged; only the File/Logger plumbing is swapped for os-lib.
// ---------------------------------------------------------------------------------------------
object ConfigMerger {
sealed trait Fmt
case object JsonFmt extends Fmt
case object TomlFmt extends Fmt
case object YamlFmt extends Fmt
final case class Target(relPath: String, fmt: Fmt, extraJson: Seq[(String, String)])
def targetFor(client: String): Target = {
val normalized = client.trim.toLowerCase.replace('_', '-')
normalized match {
case "codex" | "openai" | "openai-codex" =>
Target(".codex/config.toml", TomlFmt, Nil)
case "claude" | "claude-code" | "anthropic" =>
Target(".mcp.json", JsonFmt, Nil)
case "gemini" | "google" | "google-gemini" | "gemini-cli" =>
Target(".gemini/settings.json", JsonFmt, Seq("timeout" -> "60000"))
case "antigravity" | "antigravity-cli" | "agy" =>
Target(".agents/mcp_config.json", JsonFmt, Nil)
case "cline" =>
Target(".cline/mcp.json", JsonFmt, Seq("disabled" -> "false", "autoApprove" -> "[]"))
case "roo" | "roo-code" =>
Target(
".roo/mcp.json",
JsonFmt,
Seq("disabled" -> "false", "alwaysAllow" -> "[]", "timeout" -> "60")
)
case "continue" | "continue-dev" =>
Target(".continue/config.yaml", YamlFmt, Nil)
case "generic" | "generic-json" | "json" | "oss" | "open-source" | "free" =>
Target(".mcp.json", JsonFmt, Nil)
case other =>
sys.error(
s"Unsupported mcpClient '$other'. Use one of: " +
"claude, codex, gemini, cline, roo, continue, antigravity, generic-json."
)
}
}
// --- JSON -------------------------------------------------------------------------------------
private def jsonEntry(argv: Seq[String], extraFields: Seq[(String, String)]): String = {
val (command, args) = splitArgv(argv)
val argsJson = args.map(jsonString).mkString("[", ", ", "]")
val extra =
extraFields.map { case (name, value) => s",\n ${jsonString(name)}: $value" }.mkString
s"""|{
| "command": ${jsonString(command)},
| "args": $argsJson$extra
| }""".stripMargin
}
private def renderMcpJson(
serverName: String,
argv: Seq[String],
extraFields: Seq[(String, String)]