From 90aa8d79d12dc004a59c382ffc2738e216ebe82f Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Mon, 9 Mar 2026 14:33:37 +0100 Subject: [PATCH 01/64] Bump Mill to 1.1.3 (was 1.1.2) (#4169) --- .mill-version | 2 +- mill.bat | 2 +- millw | 2 +- website/docs/reference/cli-options.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.mill-version b/.mill-version index 45a1b3f445..781dcb07cd 100644 --- a/.mill-version +++ b/.mill-version @@ -1 +1 @@ -1.1.2 +1.1.3 diff --git a/mill.bat b/mill.bat index ef5140a0aa..8a7f54e414 100755 --- a/mill.bat +++ b/mill.bat @@ -2,7 +2,7 @@ setlocal enabledelayedexpansion -if [!DEFAULT_MILL_VERSION!]==[] ( set "DEFAULT_MILL_VERSION=1.1.2" ) +if [!DEFAULT_MILL_VERSION!]==[] ( set "DEFAULT_MILL_VERSION=1.1.3" ) if [!MILL_GITHUB_RELEASE_CDN!]==[] ( set "MILL_GITHUB_RELEASE_CDN=" ) diff --git a/millw b/millw index bc04bdcd12..77380ebba9 100755 --- a/millw +++ b/millw @@ -2,7 +2,7 @@ set -e -if [ -z "${DEFAULT_MILL_VERSION}" ] ; then DEFAULT_MILL_VERSION="1.1.2"; fi +if [ -z "${DEFAULT_MILL_VERSION}" ] ; then DEFAULT_MILL_VERSION="1.1.3"; fi if [ -z "${GITHUB_RELEASE_CDN}" ] ; then GITHUB_RELEASE_CDN=""; fi diff --git a/website/docs/reference/cli-options.md b/website/docs/reference/cli-options.md index 056df024c6..f945537b38 100644 --- a/website/docs/reference/cli-options.md +++ b/website/docs/reference/cli-options.md @@ -388,7 +388,7 @@ Version of SBT to be used for the export (1.12.4 by default) ### `--mill-version` -Version of Mill to be used for the export (1.1.2 by default) +Version of Mill to be used for the export (1.1.3 by default) ### `--mvn-version` From e4acce5ee12dbbc39f0c8b46fb0af2af0f8b5bd0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 09:38:01 +0100 Subject: [PATCH 02/64] Bump @algolia/client-search in /website in the npm-dependencies group (#4173) Bumps the npm-dependencies group in /website with 1 update: [@algolia/client-search](https://github.com/algolia/algoliasearch-client-javascript). Updates `@algolia/client-search` from 5.49.1 to 5.49.2 - [Release notes](https://github.com/algolia/algoliasearch-client-javascript/releases) - [Changelog](https://github.com/algolia/algoliasearch-client-javascript/blob/main/CHANGELOG.md) - [Commits](https://github.com/algolia/algoliasearch-client-javascript/compare/5.49.1...5.49.2) --- updated-dependencies: - dependency-name: "@algolia/client-search" dependency-version: 5.49.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: npm-dependencies ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- website/package.json | 2 +- website/yarn.lock | 54 ++++++++++++++++++++++---------------------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/website/package.json b/website/package.json index 54557eaba2..a4f98cc740 100644 --- a/website/package.json +++ b/website/package.json @@ -14,7 +14,7 @@ "write-heading-ids": "docusaurus write-heading-ids" }, "dependencies": { - "@algolia/client-search": "^5.49.1", + "@algolia/client-search": "^5.49.2", "@docusaurus/core": "^3.9.2", "@docusaurus/plugin-content-docs": "^3.9.2", "@docusaurus/preset-classic": "^3.9.2", diff --git a/website/yarn.lock b/website/yarn.lock index a2bf5b1c23..52f36738dd 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -92,10 +92,10 @@ resolved "https://registry.yarnpkg.com/@algolia/client-common/-/client-common-5.46.0.tgz#004ad40adbdc6da7e23e4ef4d7a0ff48422af012" integrity sha512-0emZTaYOeI9WzJi0TcNd2k3SxiN6DZfdWc2x2gHt855Jl9jPUOzfVTL6gTvCCrOlT4McvpDGg5nGO+9doEjjig== -"@algolia/client-common@5.49.1": - version "5.49.1" - resolved "https://registry.yarnpkg.com/@algolia/client-common/-/client-common-5.49.1.tgz#2b52313a9027bba5c57abd76d652fd4b16f56a32" - integrity sha512-vp5/a9ikqvf3mn9QvHN8PRekn8hW34aV9eX+O0J5mKPZXeA6Pd5OQEh2ZWf7gJY6yyfTlLp5LMFzQUAU+Fpqpg== +"@algolia/client-common@5.49.2": + version "5.49.2" + resolved "https://registry.yarnpkg.com/@algolia/client-common/-/client-common-5.49.2.tgz#cb93f1ea9a60f7ffec65474e075afb52900f2434" + integrity sha512-bn0biLequn3epobCfjUqCxlIlurLr4RHu7RaE4trgN+RDcUq6HCVC3/yqq1hwbNYpVtulnTOJzcaxYlSr1fnuw== "@algolia/client-insights@5.46.0": version "5.46.0" @@ -127,15 +127,15 @@ "@algolia/requester-fetch" "5.46.0" "@algolia/requester-node-http" "5.46.0" -"@algolia/client-search@5.46.0", "@algolia/client-search@^5.48.1", "@algolia/client-search@^5.49.1": - version "5.49.1" - resolved "https://registry.yarnpkg.com/@algolia/client-search/-/client-search-5.49.1.tgz#c16518fb5003b4a35b74bdee7a168d4d7a09877b" - integrity sha512-Nt9hri7nbOo0RipAsGjIssHkpLMHHN/P7QqENywAq5TLsoYDzUyJGny8FEiD/9KJUxtGH8blGpMedilI6kK3rA== +"@algolia/client-search@5.46.0", "@algolia/client-search@^5.48.1", "@algolia/client-search@^5.49.2": + version "5.49.2" + resolved "https://registry.yarnpkg.com/@algolia/client-search/-/client-search-5.49.2.tgz#3c823ffaf333ce70fedfb3d45361661c8b227806" + integrity sha512-y1IOpG6OSmTpGg/CT0YBb/EAhR2nsC18QWp9Jy8HO9iGySpcwaTvs5kHa17daP3BMTwWyaX9/1tDTDQshZzXdg== dependencies: - "@algolia/client-common" "5.49.1" - "@algolia/requester-browser-xhr" "5.49.1" - "@algolia/requester-fetch" "5.49.1" - "@algolia/requester-node-http" "5.49.1" + "@algolia/client-common" "5.49.2" + "@algolia/requester-browser-xhr" "5.49.2" + "@algolia/requester-fetch" "5.49.2" + "@algolia/requester-node-http" "5.49.2" "@algolia/events@^4.0.1": version "4.0.1" @@ -179,12 +179,12 @@ dependencies: "@algolia/client-common" "5.46.0" -"@algolia/requester-browser-xhr@5.49.1": - version "5.49.1" - resolved "https://registry.yarnpkg.com/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.49.1.tgz#0096c02e6d60fc79a71f80b51315e9a8fe7da5dc" - integrity sha512-2UPyRuUR/qpqSqH8mxFV5uBZWEpxhGPHLlx9Xf6OVxr79XO2ctzZQAhsmTZ6X22x+N8MBWpB9UEky7YU2HGFgA== +"@algolia/requester-browser-xhr@5.49.2": + version "5.49.2" + resolved "https://registry.yarnpkg.com/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.49.2.tgz#4493ed41dc84948693b34174ccc08ac5dfabd3dd" + integrity sha512-3UhYCcWX6fbtN8ABcxZlhaQEwXFh3CsFtARyyadQShHMPe3mJV9Wel4FpJTa+seugRkbezFz0tt6aPTZSYTBuA== dependencies: - "@algolia/client-common" "5.49.1" + "@algolia/client-common" "5.49.2" "@algolia/requester-fetch@5.46.0": version "5.46.0" @@ -193,12 +193,12 @@ dependencies: "@algolia/client-common" "5.46.0" -"@algolia/requester-fetch@5.49.1": - version "5.49.1" - resolved "https://registry.yarnpkg.com/@algolia/requester-fetch/-/requester-fetch-5.49.1.tgz#bc41f0d03d0bc3c8decdc65ec08d29e992b16f1c" - integrity sha512-N+xlE4lN+wpuT+4vhNEwPVlrfN+DWAZmSX9SYhbz986Oq8AMsqdntOqUyiOXVxYsQtfLwmiej24vbvJGYv1Qtw== +"@algolia/requester-fetch@5.49.2": + version "5.49.2" + resolved "https://registry.yarnpkg.com/@algolia/requester-fetch/-/requester-fetch-5.49.2.tgz#fd64e1ec726ffb63dce22112354119125c67a27e" + integrity sha512-G94VKSGbsr+WjsDDOBe5QDQ82QYgxvpxRGJfCHZBnYKYsy/jv9qGIDb93biza+LJWizQBUtDj7bZzp3QZyzhPQ== dependencies: - "@algolia/client-common" "5.49.1" + "@algolia/client-common" "5.49.2" "@algolia/requester-node-http@5.46.0": version "5.46.0" @@ -207,12 +207,12 @@ dependencies: "@algolia/client-common" "5.46.0" -"@algolia/requester-node-http@5.49.1": - version "5.49.1" - resolved "https://registry.yarnpkg.com/@algolia/requester-node-http/-/requester-node-http-5.49.1.tgz#34846a9a2d7fee6667a4767588b8e77b2c6d9e12" - integrity sha512-zA5bkUOB5PPtTr182DJmajCiizHp0rCJQ0Chf96zNFvkdESKYlDeYA3tQ7r2oyHbu/8DiohAQ5PZ85edctzbXA== +"@algolia/requester-node-http@5.49.2": + version "5.49.2" + resolved "https://registry.yarnpkg.com/@algolia/requester-node-http/-/requester-node-http-5.49.2.tgz#ac71c6502b8ba8760d22afd3f38620934a28a68f" + integrity sha512-UuihBGHafG/ENsrcTGAn5rsOffrCIRuHMOsD85fZGLEY92ate+BMTUqxz60dv5zerh8ZumN4bRm8eW2z9L11jA== dependencies: - "@algolia/client-common" "5.49.1" + "@algolia/client-common" "5.49.2" "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.27.1": version "7.27.1" From 9c2adc061b19323772d6b413b509b953aafe56f8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:15:33 +0100 Subject: [PATCH 03/64] Bump the github-actions group with 4 updates (#4172) Bumps the github-actions group with 4 updates: [docker/login-action](https://github.com/docker/login-action), [docker/metadata-action](https://github.com/docker/metadata-action), [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) and [docker/build-push-action](https://github.com/docker/build-push-action). Updates `docker/login-action` from 3 to 4 - [Release notes](https://github.com/docker/login-action/releases) - [Commits](https://github.com/docker/login-action/compare/v3...v4) Updates `docker/metadata-action` from 5 to 6 - [Release notes](https://github.com/docker/metadata-action/releases) - [Commits](https://github.com/docker/metadata-action/compare/v5...v6) Updates `docker/setup-buildx-action` from 3 to 4 - [Release notes](https://github.com/docker/setup-buildx-action/releases) - [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4) Updates `docker/build-push-action` from 6 to 7 - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/v6...v7) --- updated-dependencies: - dependency-name: docker/login-action dependency-version: '4' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: docker/metadata-action dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: docker/setup-buildx-action dependency-version: '4' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: docker/build-push-action dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- .github/workflows/publish-docker.yml | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc653938c1..94ae018a4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1065,7 +1065,7 @@ jobs: path: test-report.xml - name: Login to GitHub Container Registry if: startsWith(github.ref, 'refs/tags/v') - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -1178,7 +1178,7 @@ jobs: path: test-report.xml - name: Login to GitHub Container Registry if: startsWith(github.ref, 'refs/tags/v') - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index 71da926e89..973cdfa933 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -35,7 +35,7 @@ jobs: uses: actions/checkout@v6 - name: Log in to the Container registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ env.REGISTRY_LOGIN }} @@ -44,19 +44,19 @@ jobs: # This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels. - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 # This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages. # It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository. # It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step. - name: Build and push Docker image id: push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . file: ${{ env.DOCKERFILE }} @@ -106,10 +106,10 @@ jobs: merge-multiple: true - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Log in to the Container registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ env.REGISTRY_LOGIN }} @@ -117,7 +117,7 @@ jobs: - name: Docker meta id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} From 4763be0b0adccfaeb0cf919640c3c75a19cc8c21 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Thu, 12 Mar 2026 12:42:29 +0100 Subject: [PATCH 04/64] Update Scala 3 Next RC to 3.8.3-RC2 (#4175) --- project/deps/package.mill | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/project/deps/package.mill b/project/deps/package.mill index f12ef82f94..e8f98b0216 100644 --- a/project/deps/package.mill +++ b/project/deps/package.mill @@ -21,8 +21,8 @@ object Scala { def scala3NextPrefix = "3.8" def scala3Next = s"$scala3NextPrefix.2" // the newest/next version of Scala def scala3NextAnnounced = scala3Next // the newest/next version of Scala that's been announced - def scala3NextRc = "3.8.3-RC1" // the latest RC version of Scala Next - def scala3NextRcAnnounced = "3.8.2-RC3" // the latest announced RC version of Scala Next + def scala3NextRc = "3.8.3-RC2" // the latest RC version of Scala Next + def scala3NextRcAnnounced = "3.8.3-RC1" // the latest announced RC version of Scala Next // The Scala version used to build the CLI itself. def defaultInternal = sys.props.get("scala.version.internal").getOrElse(scala3Lts) From 1631f8c7ea5c7ea0e4b0deccb213b484ccae548d Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Thu, 12 Mar 2026 13:33:31 +0100 Subject: [PATCH 05/64] Refer the Scala 3 compiler policy on usage of LLM-based tools in contributions --- CONTRIBUTING.md | 3 +++ LLM_POLICY.md | 7 +++++++ 2 files changed, 10 insertions(+) create mode 100644 LLM_POLICY.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 08fa9c9af0..1b30ec4cc6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,6 +35,9 @@ A subsequent PR from `stable` back to `main` is created automatically. Whenever reasonable, we try to follow the following set of rules when merging code to the repository. Following those will save you from getting a load of comments and speed up the code review. +- If you are using LLM-based tools to assist you in your contribution, state that clearly in the PR description + and refer to our [LLM usage policy](LLM_POLICY.md) for rules and guidelines regarding usage of LLM-based tools + in contributions. - If the PR is meant to be merged as a single commit (`squash & merge`), please make sure that you modify only one thing. - This means such a PR shouldn't include code clean-up, a secondary feature or bug fix, just the single thing diff --git a/LLM_POLICY.md b/LLM_POLICY.md new file mode 100644 index 0000000000..f666ad5968 --- /dev/null +++ b/LLM_POLICY.md @@ -0,0 +1,7 @@ +# Policy regarding LLM-generated code in contributions to Scala CLI + +Scala CLI accepts contributions containing code produced with AI assistance. This means that using LLM-based +tooling aiding software development (like Cursor, Claude Code, Copilot or whatever else) is allowed. + +All such contributions are regulated by the policy defined in the Scala 3 compiler repository, which can be found at: +https://github.com/scala/scala3/blob/main/LLM_POLICY.md \ No newline at end of file From 03ec06a3651761070d497e5cafcd312ca0241958 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Thu, 12 Mar 2026 13:33:50 +0100 Subject: [PATCH 06/64] Add a PR template --- .github/pull_request_template.md | 38 ++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 2 +- 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 .github/pull_request_template.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000..e006e2b969 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,38 @@ + + + + + + +## Checklist +- [ ] tested the solution locally and it works +- [ ] ran the code formatter (`scala-cli fmt .`) +- [ ] ran `scalafix` (`./mill -i __.fix`) +- [ ] ran reference docs auto-generation (`./mill -i 'generate-reference-doc[]'.run`) + +## How much have your relied on LLM-based tools in this contribution? + + + + + +## How was the solution tested? + + + +## Additional notes + + + + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1b30ec4cc6..9f297b2938 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,7 +57,7 @@ will save you from getting a load of comments and speed up the code review. Other notes: -- give a short explanation on what the PR is meant to achieve in the description, unless covered by the PR title; +- fill the pull request template; - make sure to add tests wherever possible; - favor unit tests over integration tests where applicable; - try to add scaladocs for key classes and functions; From 48fa471e316ac3a727396b5c781f100289e28219 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Thu, 12 Mar 2026 13:37:55 +0100 Subject: [PATCH 07/64] Support `--cross` with the `package` sub-command (#4171) --- .../scala/cli/commands/package0/Package.scala | 85 +++++++++++++++++-- .../integration/PackageTestDefinitions.scala | 34 ++++---- .../cli/integration/PackageTestsDefault.scala | 81 +++++++++++++++++- 3 files changed, 172 insertions(+), 28 deletions(-) diff --git a/modules/cli/src/main/scala/scala/cli/commands/package0/Package.scala b/modules/cli/src/main/scala/scala/cli/commands/package0/Package.scala index 6fecd3c98d..a5828a837d 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/package0/Package.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/package0/Package.scala @@ -101,16 +101,16 @@ object Package extends ScalaCommand[PackageOptions] with BuildCommandHelpers { actionableDiagnostics = actionableDiagnostics, postAction = () => WatchUtil.printWatchMessage() ) { res => - res.orReport(logger).map(_.builds).foreach { + res.orReport(logger).map(_.all).foreach { case b if b.forall(_.success) => val successfulBuilds = b.collect { case s: Build.Successful => s } successfulBuilds.foreach(_.copyOutput(options.shared)) - val mtimeDestPath = doPackage( + val mtimeDestPath = doPackageCrossBuilds( logger = logger, outputOpt = options.output.filter(_.nonEmpty), force = options.force, forcedPackageTypeOpt = options.forcedPackageTypeOpt, - builds = successfulBuilds, + allBuilds = successfulBuilds, extraArgs = args.unparsed, expectedModifyEpochSecondOpt = expectedModifyEpochSecondOpt, allowTerminate = !options.watch.watchMode, @@ -141,16 +141,16 @@ object Package extends ScalaCommand[PackageOptions] with BuildCommandHelpers { actionableDiagnostics = actionableDiagnostics ) .orExit(logger) - .builds match { + .all match { case b if b.forall(_.success) => val successfulBuilds = b.collect { case s: Build.Successful => s } successfulBuilds.foreach(_.copyOutput(options.shared)) - val res0 = doPackage( + val res0 = doPackageCrossBuilds( logger = logger, outputOpt = options.output.filter(_.nonEmpty), force = options.force, forcedPackageTypeOpt = options.forcedPackageTypeOpt, - builds = successfulBuilds, + allBuilds = successfulBuilds, extraArgs = args.unparsed, expectedModifyEpochSecondOpt = None, allowTerminate = !options.watch.watchMode, @@ -183,6 +183,69 @@ object Package extends ScalaCommand[PackageOptions] with BuildCommandHelpers { buildOptions } + private def insertSuffixBeforeExtension(name: String, suffix: String): String = + if suffix.isEmpty then name + else { + val dotIdx = name.lastIndexOf('.') + if dotIdx > 0 then name.substring(0, dotIdx) + suffix + name.substring(dotIdx) + else name + suffix + } + + private def doPackageCrossBuilds( + logger: Logger, + outputOpt: Option[String], + force: Boolean, + forcedPackageTypeOpt: Option[PackageType], + allBuilds: Seq[Build.Successful], + extraArgs: Seq[String], + expectedModifyEpochSecondOpt: Option[Long], + allowTerminate: Boolean, + mainClassOptions: MainClassOptions, + withTestScope: Boolean + ): Either[BuildException, Option[Long]] = either { + val crossBuildGroups = allBuilds.groupedByCrossParams.toSeq + val multipleCrossGroups = crossBuildGroups.size > 1 + + if multipleCrossGroups then + logger.message(s"Packaging ${crossBuildGroups.size} cross builds...") + + val platforms = crossBuildGroups.map(_._1.platform).distinct + val needsPlatformInSuffix = platforms.size > 1 + + val results = value { + crossBuildGroups.map { (crossParams, builds) => + val crossSuffix = + if multipleCrossGroups then { + val versionPart = s"_${crossParams.scalaVersion}" + if needsPlatformInSuffix then s"${versionPart}_${crossParams.platform}" + else versionPart + } + else "" + + if multipleCrossGroups then + logger.message(s"Packaging for ${crossParams.asString}...") + + doPackage( + logger = logger, + outputOpt = outputOpt, + force = force, + forcedPackageTypeOpt = forcedPackageTypeOpt, + builds = builds, + extraArgs = extraArgs, + expectedModifyEpochSecondOpt = expectedModifyEpochSecondOpt, + allowTerminate = allowTerminate, + mainClassOptions = mainClassOptions, + withTestScope = withTestScope, + crossSuffix = crossSuffix + ) + } + .sequence + .left.map(CompositeBuildException(_)) + } + + results.lastOption.flatten + } + private def doPackage( logger: Logger, outputOpt: Option[String], @@ -193,7 +256,8 @@ object Package extends ScalaCommand[PackageOptions] with BuildCommandHelpers { expectedModifyEpochSecondOpt: Option[Long], allowTerminate: Boolean, mainClassOptions: MainClassOptions, - withTestScope: Boolean + withTestScope: Boolean, + crossSuffix: String ): Either[BuildException, Option[Long]] = either { if mainClassOptions.mainClassLs.contains(true) then value { @@ -285,7 +349,12 @@ object Package extends ScalaCommand[PackageOptions] with BuildCommandHelpers { } .orElse(builds.flatMap(_.sources.paths).collectFirst(_._1.baseName + extension)) .getOrElse(defaultName) - val destPath = os.Path(dest, Os.pwd) + val destPath = { + val base = os.Path(dest, Os.pwd) + if crossSuffix.nonEmpty then + base / os.up / insertSuffixBeforeExtension(base.last, crossSuffix) + else base + } val printableDest = CommandUtils.printablePath(destPath) def alreadyExistsCheck(): Either[BuildException, Unit] = diff --git a/modules/integration/src/test/scala/scala/cli/integration/PackageTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/PackageTestDefinitions.scala index ed2d70c6a0..3be1068114 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/PackageTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/PackageTestDefinitions.scala @@ -1498,15 +1498,17 @@ abstract class PackageTestDefinitions extends ScalaCliSuite with TestScalaVersio } } - if (actualScalaVersion == Constants.scala3Next) - test(s"package ($packageDescription, --cross)") { + if (actualScalaVersion == Constants.scala3Next) { + val crossScalaVersions = + Seq(actualScalaVersion, Constants.scala213, Constants.scala212) + val numberOfBuilds = crossScalaVersions.size + test(s"package ($packageDescription, --cross) produces $numberOfBuilds artifacts") { TestUtil.retryOnCi() { val crossDirective = - s"//> using scala $actualScalaVersion ${Constants.scala213} ${Constants.scala212}" - val mainClass = "TestScopeMain" - val mainFile = s"$mainClass.scala" - val message = "Hello" - val outputFile = mainClass + extension + s"//> using scala ${crossScalaVersions.mkString(" ")}" + val mainClass = "TestScopeMain" + val mainFile = s"$mainClass.scala" + val message = "Hello" TestInputs( os.rel / "Messages.scala" -> s"""$crossDirective @@ -1524,21 +1526,15 @@ abstract class PackageTestDefinitions extends ScalaCliSuite with TestScalaVersio packageOpts ) .call(cwd = root) - val outputFilePath = root / outputFile - expect(os.isFile(outputFilePath)) - val output = - if (packageDescription == libraryArg) - os.proc(TestUtil.cli, "run", outputFilePath).call(cwd = root).out.trim() - else if (packageDescription == jsArg) - os.proc(node, outputFilePath).call(cwd = root).out.trim() - else { - expect(Files.isExecutable(outputFilePath.toNIO)) - TestUtil.maybeUseBash(outputFilePath)(cwd = root).out.trim() - } - expect(output == message) + + crossScalaVersions.foreach { version => + val outputFilePath = root / s"${mainClass}_$version$extension" + expect(os.isFile(outputFilePath)) + } } } } + } } } diff --git a/modules/integration/src/test/scala/scala/cli/integration/PackageTestsDefault.scala b/modules/integration/src/test/scala/scala/cli/integration/PackageTestsDefault.scala index 2932204d86..7f14733e08 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/PackageTestsDefault.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/PackageTestsDefault.scala @@ -2,6 +2,10 @@ package scala.cli.integration import com.eed3si9n.expecty.Expecty.expect +import java.nio.file.Files + +import scala.util.Properties + class PackageTestsDefault extends PackageTestDefinitions with TestDefault { test("reuse run native binary") { TestUtil.retryOnCi() { @@ -25,10 +29,85 @@ class PackageTestsDefault extends PackageTestDefinitions with TestDefault { val packageOutput = packageRes.out.trim() val topPackageOutput = packageOutput.linesIterator.takeWhile(!_.startsWith("Wrote ")).toVector - // no compilation or Scala Native pipeline output, as this should just re-use what the run command wrote expect(topPackageOutput.forall(!_.startsWith("[info] "))) } } } + for { + (packageOpts, extension) <- Seq( + Nil -> (if (Properties.isWin) ".bat" else ""), + Seq("--library") -> ".jar" + ) ++ + (if (!TestUtil.isNativeCli || !Properties.isWin) Seq( + Seq("--assembly") -> ".jar" + ) + else Nil) + packageDescription = packageOpts.headOption.getOrElse("bootstrap") + crossScalaVersions = Seq(actualScalaVersion, Constants.scala213, Constants.scala212) + numberOfBuilds = crossScalaVersions.size + } { + test(s"package --cross ($packageDescription) produces $numberOfBuilds artifacts") { + TestUtil.retryOnCi() { + val mainClass = "Main" + val message = "Hello" + TestInputs( + os.rel / "project.scala" -> s"//> using scala ${crossScalaVersions.mkString(" ")}", + os.rel / s"$mainClass.scala" -> + s"""object $mainClass extends App { println("$message") }""" + ).fromRoot { root => + os.proc( + TestUtil.cli, + "--power", + "package", + "--cross", + extraOptions, + ".", + packageOpts + ).call(cwd = root) + + crossScalaVersions.foreach { version => + val expectedFile = root / s"${mainClass}_$version$extension" + expect(os.isFile(expectedFile)) + } + + if packageDescription == "bootstrap" then + crossScalaVersions.foreach { version => + val outputFile = root / s"${mainClass}_$version$extension" + expect(Files.isExecutable(outputFile.toNIO)) + val output = TestUtil.maybeUseBash(outputFile)(cwd = root).out.trim() + expect(output == message) + } + } + } + } + + test(s"package without --cross ($packageDescription) produces single artifact") { + TestUtil.retryOnCi() { + val mainClass = "Main" + val message = "Hello" + TestInputs( + os.rel / "project.scala" -> s"//> using scala ${crossScalaVersions.mkString(" ")}", + os.rel / s"$mainClass.scala" -> + s"""object $mainClass extends App { println("$message") }""" + ).fromRoot { root => + val r = os.proc( + TestUtil.cli, + "--power", + "package", + extraOptions, + ".", + packageOpts + ).call(cwd = root, stderr = os.Pipe) + + val expectedFile = root / s"$mainClass$extension" + expect(os.isFile(expectedFile)) + + expect(r.err.trim().contains(s"ignoring ${numberOfBuilds - 1} builds")) + expect(r.err.trim().contains(s"Defaulting to Scala $actualScalaVersion")) + } + } + } + } + } From 6c20fe0124a57362832a7d4964ff84a89376b486 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Thu, 12 Mar 2026 14:55:23 +0100 Subject: [PATCH 08/64] Allow to `--watch` extra paths with `--watching` (#4174) --- .../src/main/scala/scala/build/Build.scala | 13 ++ .../DirectivesPreprocessingUtils.scala | 1 + .../scala/cli/commands/compile/Compile.scala | 3 + .../cli/commands/compile/CompileOptions.scala | 2 +- .../commands/package0/PackageOptions.scala | 4 +- .../scala/cli/commands/publish/Publish.scala | 3 + .../cli/commands/publish/PublishLocal.scala | 3 + .../publish/PublishLocalOptions.scala | 2 +- .../cli/commands/publish/PublishOptions.scala | 2 +- .../scala/scala/cli/commands/repl/Repl.scala | 2 +- .../scala/scala/cli/commands/run/Run.scala | 2 +- .../scala/cli/commands/run/RunOptions.scala | 10 +- .../shared/HasSharedWatchOptions.scala | 11 ++ .../cli/commands/shared/SharedOptions.scala | 7 +- .../commands/shared/SharedWatchOptions.scala | 15 +- .../scala/scala/cli/commands/test/Test.scala | 2 +- .../preprocessing/directives/Watching.scala | 48 +++++++ .../integration/CompileTestDefinitions.scala | 41 ++++++ .../integration/PackageTestDefinitions.scala | 42 ++++++ .../RunWithWatchTestDefinitions.scala | 129 ++++++++++++++++++ .../cli/integration/TestTestDefinitions.scala | 49 ++++++- .../scala/cli/integration/TestUtil.scala | 30 ++++ .../scala/build/options/BuildOptions.scala | 1 + .../scala/build/options/WatchOptions.scala | 10 ++ website/docs/commands/compile.md | 16 +++ website/docs/commands/package.md | 22 +++ website/docs/commands/repl.md | 20 +++ website/docs/commands/run.md | 16 +++ website/docs/commands/test.md | 22 +++ website/docs/reference/cli-options.md | 6 + website/docs/reference/directives.md | 13 ++ 31 files changed, 532 insertions(+), 15 deletions(-) create mode 100644 modules/cli/src/main/scala/scala/cli/commands/shared/HasSharedWatchOptions.scala create mode 100644 modules/directives/src/main/scala/scala/build/preprocessing/directives/Watching.scala create mode 100644 modules/options/src/main/scala/scala/build/options/WatchOptions.scala diff --git a/modules/build/src/main/scala/scala/build/Build.scala b/modules/build/src/main/scala/scala/build/Build.scala index c631c88ea0..89377a0daa 100644 --- a/modules/build/src/main/scala/scala/build/Build.scala +++ b/modules/build/src/main/scala/scala/build/Build.scala @@ -17,6 +17,7 @@ import scala.build.errors.* import scala.build.input.* import scala.build.internal.resource.ResourceMapper import scala.build.internal.{Constants, MainClass, Name, Util} +import scala.build.internals.ConsoleUtils.ScalaCliConsole.warnPrefix import scala.build.options.* import scala.build.options.validation.ValidationException import scala.build.postprocessing.* @@ -791,6 +792,7 @@ object Build { def doWatch(): Unit = either { val (crossSources: CrossSources, inputs0: Inputs) = value(allInputs(inputs, options, logger)) + val mergedOptions = crossSources.sharedOptions(options) val elements: Seq[Element] = if res == null then inputs0.elements else @@ -851,6 +853,17 @@ object Build { watcher0.register(artifact.toNIO, depth) watcher0.addObserver(onChangeBufferedObserver(_ => watcher.schedule())) } + + val extraWatchPaths = mergedOptions.watchOptions.extraWatchPaths.distinct + for (extraPath <- extraWatchPaths) + if os.exists(extraPath) then { + val depth = if os.isFile(extraPath) then -1 else Int.MaxValue + val watcher0 = watcher.newWatcher() + watcher0.register(extraPath.toNIO, depth) + watcher0.addObserver(onChangeBufferedObserver(_ => watcher.schedule())) + } + else + logger.message(s"$warnPrefix provided watched path doesn't exist: $extraPath") } try doWatch() diff --git a/modules/build/src/main/scala/scala/build/preprocessing/directives/DirectivesPreprocessingUtils.scala b/modules/build/src/main/scala/scala/build/preprocessing/directives/DirectivesPreprocessingUtils.scala index 96c9edc30a..5b439b07fc 100644 --- a/modules/build/src/main/scala/scala/build/preprocessing/directives/DirectivesPreprocessingUtils.scala +++ b/modules/build/src/main/scala/scala/build/preprocessing/directives/DirectivesPreprocessingUtils.scala @@ -31,6 +31,7 @@ object DirectivesPreprocessingUtils { directives.ScalaNative.handler, directives.ScalaVersion.handler, directives.Sources.handler, + directives.Watching.handler, directives.Tests.handler ).map(_.mapE(_.buildOptions)) diff --git a/modules/cli/src/main/scala/scala/cli/commands/compile/Compile.scala b/modules/cli/src/main/scala/scala/cli/commands/compile/Compile.scala index bb0203273f..7a5e36dfc9 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/compile/Compile.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/compile/Compile.scala @@ -23,6 +23,9 @@ object Compile extends ScalaCommand[CompileOptions] with BuildCommandHelpers { override def sharedOptions(options: CompileOptions): Option[SharedOptions] = Some(options.shared) + override def buildOptions(options: CompileOptions): Some[scala.build.options.BuildOptions] = + Some(options.buildOptions().orExit(options.shared.logger)) + override def scalaSpecificationLevel: SpecificationLevel = SpecificationLevel.MUST val primaryHelpGroups: Seq[HelpGroup] = Seq( diff --git a/modules/cli/src/main/scala/scala/cli/commands/compile/CompileOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/compile/CompileOptions.scala index fafbb6d8c9..cdd8326ee9 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/compile/CompileOptions.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/compile/CompileOptions.scala @@ -23,7 +23,7 @@ final case class CompileOptions( @Tag(tags.should) @Tag(tags.inShortHelp) printClassPath: Boolean = false -) extends HasSharedOptions +) extends HasSharedOptions with HasSharedWatchOptions // format: on object CompileOptions { diff --git a/modules/cli/src/main/scala/scala/cli/commands/package0/PackageOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/package0/PackageOptions.scala index d9d3a350f9..6a7c7e5f41 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/package0/PackageOptions.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/package0/PackageOptions.scala @@ -141,7 +141,7 @@ final case class PackageOptions( @Tag(tags.restricted) @Tag(tags.inShortHelp) nativeImage: Boolean = false -) extends HasSharedOptions { +) extends HasSharedOptions with HasSharedWatchOptions { // format: on def packageTypeOpt: Option[PackageType] = @@ -177,7 +177,7 @@ final case class PackageOptions( .left.map(CompositeBuildException(_)) def baseBuildOptions(logger: Logger): Either[BuildException, BuildOptions] = either { - val baseOptions = value(shared.buildOptions()) + val baseOptions = value(buildOptions()) baseOptions.copy( mainClass = mainClass.mainClass.filter(_.nonEmpty), notForBloopOptions = baseOptions.notForBloopOptions.copy( diff --git a/modules/cli/src/main/scala/scala/cli/commands/publish/Publish.scala b/modules/cli/src/main/scala/scala/cli/commands/publish/Publish.scala index c832c9daf7..efe1f1e7ab 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/publish/Publish.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/publish/Publish.scala @@ -84,6 +84,9 @@ object Publish extends ScalaCommand[PublishOptions] with BuildCommandHelpers { override def sharedOptions(options: PublishOptions): Option[SharedOptions] = Some(options.shared) + override def buildOptions(options: PublishOptions): Some[BuildOptions] = + Some(options.buildOptions().orExit(options.shared.logger)) + def mkBuildOptions( baseOptions: BuildOptions, sharedVersionOptions: SharedVersionOptions, diff --git a/modules/cli/src/main/scala/scala/cli/commands/publish/PublishLocal.scala b/modules/cli/src/main/scala/scala/cli/commands/publish/PublishLocal.scala index 8355c2df8c..d81158c7ff 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/publish/PublishLocal.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/publish/PublishLocal.scala @@ -20,6 +20,9 @@ object PublishLocal extends ScalaCommand[PublishLocalOptions] { override def sharedOptions(options: PublishLocalOptions): Option[SharedOptions] = Some(options.shared) + override def buildOptions(options: PublishLocalOptions): Some[scala.build.options.BuildOptions] = + Some(options.buildOptions().orExit(options.shared.logger)) + override def names: List[List[String]] = List( List("publish", "local") ) diff --git a/modules/cli/src/main/scala/scala/cli/commands/publish/PublishLocalOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/publish/PublishLocalOptions.scala index f131f33a73..e41bca8793 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/publish/PublishLocalOptions.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/publish/PublishLocalOptions.scala @@ -22,7 +22,7 @@ final case class PublishLocalOptions( sharedPublish: SharedPublishOptions = SharedPublishOptions(), @Recurse scalaSigning: PgpScalaSigningOptions = PgpScalaSigningOptions(), -) extends HasSharedOptions +) extends HasSharedOptions with HasSharedWatchOptions // format: on object PublishLocalOptions { diff --git a/modules/cli/src/main/scala/scala/cli/commands/publish/PublishOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/publish/PublishOptions.scala index ac9a88c8d6..5a10a2049d 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/publish/PublishOptions.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/publish/PublishOptions.scala @@ -38,7 +38,7 @@ final case class PublishOptions( @Tag(tags.restricted) @Hidden parallelUpload: Option[Boolean] = None -) extends HasSharedOptions +) extends HasSharedOptions with HasSharedWatchOptions // format: on object PublishOptions { diff --git a/modules/cli/src/main/scala/scala/cli/commands/repl/Repl.scala b/modules/cli/src/main/scala/scala/cli/commands/repl/Repl.scala index 209ecf5797..d0dff113e9 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/repl/Repl.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/repl/Repl.scala @@ -62,7 +62,7 @@ object Repl extends ScalaCommand[ReplOptions] with BuildCommandHelpers { val logger = ops.shared.logger val ammoniteVersionOpt = ammoniteVersion.map(_.trim).filter(_.nonEmpty) - val baseOptions = shared.buildOptions().orExit(logger) + val baseOptions = shared.buildOptions(watchOptions = watch).orExit(logger) val maybeDowngradedScalaVersion = { val isDefaultAmmonite = ammonite.contains(true) && ammoniteVersionOpt.isEmpty diff --git a/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala b/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala index b453f326b0..78b53b5bfb 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala @@ -71,7 +71,7 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers { import options.* import options.sharedRun.* val logger = options.shared.logger - val baseOptions = shared.buildOptions().orExit(logger) + val baseOptions = options.buildOptions().orExit(logger) baseOptions.copy( mainClass = mainClass.mainClass, javaOptions = baseOptions.javaOptions.copy( diff --git a/modules/cli/src/main/scala/scala/cli/commands/run/RunOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/run/RunOptions.scala index 3007b74cba..194e122f5d 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/run/RunOptions.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/run/RunOptions.scala @@ -4,7 +4,9 @@ import caseapp.* import caseapp.core.help.Help import scala.cli.ScalaCli -import scala.cli.commands.shared.{HasSharedOptions, HelpMessages, SharedOptions} +import scala.cli.commands.shared.{ + HasSharedOptions, HasSharedWatchOptions, HelpMessages, SharedOptions, SharedWatchOptions +} @HelpMessage(RunOptions.helpMessage, "", RunOptions.detailedHelpMessage) // format: off @@ -13,8 +15,10 @@ final case class RunOptions( shared: SharedOptions = SharedOptions(), @Recurse sharedRun: SharedRunOptions = SharedRunOptions() -) extends HasSharedOptions -// format: on +) extends HasSharedOptions with HasSharedWatchOptions { + // format: on + override def watch: SharedWatchOptions = sharedRun.watch +} object RunOptions { implicit lazy val parser: Parser[RunOptions] = Parser.derive diff --git a/modules/cli/src/main/scala/scala/cli/commands/shared/HasSharedWatchOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/shared/HasSharedWatchOptions.scala new file mode 100644 index 0000000000..eef9534d53 --- /dev/null +++ b/modules/cli/src/main/scala/scala/cli/commands/shared/HasSharedWatchOptions.scala @@ -0,0 +1,11 @@ +package scala.cli.commands.shared + +import scala.build.errors.BuildException + +trait HasSharedWatchOptions { this: HasSharedOptions => + def watch: SharedWatchOptions + + def buildOptions(ignoreErrors: Boolean = + false): Either[BuildException, scala.build.options.BuildOptions] = + shared.buildOptions(ignoreErrors = ignoreErrors, watchOptions = watch) +} diff --git a/modules/cli/src/main/scala/scala/cli/commands/shared/SharedOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/shared/SharedOptions.scala index fe4b8903d9..6be30799ea 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/shared/SharedOptions.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/shared/SharedOptions.scala @@ -290,7 +290,10 @@ final case class SharedOptions( def scalacOptions: List[String] = scalac.scalacOption ++ scalacOptionsFromFiles - def buildOptions(ignoreErrors: Boolean = false) + def buildOptions( + ignoreErrors: Boolean = false, + watchOptions: SharedWatchOptions = SharedWatchOptions() + ) : Either[BuildException, scala.build.options.BuildOptions] = either { val releaseOpt = scalacOptions.getScalacOption("-release") @@ -441,7 +444,7 @@ final case class SharedOptions( scalaPyVersion = sharedPython.scalaPyVersion ), useBuildServer = compilationServer.server - ) + ).orElse(watchOptions.buildOptions()) } private def resolvedDependencies( diff --git a/modules/cli/src/main/scala/scala/cli/commands/shared/SharedWatchOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/shared/SharedWatchOptions.scala index df4e28ec7e..2fd9a45cdf 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/shared/SharedWatchOptions.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/shared/SharedWatchOptions.scala @@ -2,6 +2,7 @@ package scala.cli.commands.shared import caseapp.* +import scala.build.options.{BuildOptions, WatchOptions} import scala.cli.commands.tags // format: off @@ -18,10 +19,22 @@ final case class SharedWatchOptions( @Tag(tags.should) @Tag(tags.inShortHelp) @Name("revolver") - restart: Boolean = false + restart: Boolean = false, + @Group(HelpGroup.Watch.toString) + @HelpMessage("Watch additional paths for changes (used together with --watch or --restart)") + @Tag(tags.experimental) + @Name("watchingPath") + watching: List[String] = Nil ) { // format: on lazy val watchMode: Boolean = watch || restart + + def buildOptions(cwd: os.Path = os.pwd): BuildOptions = + BuildOptions( + watchOptions = WatchOptions( + extraWatchPaths = watching.map(os.Path(_, cwd)) + ) + ) } object SharedWatchOptions { diff --git a/modules/cli/src/main/scala/scala/cli/commands/test/Test.scala b/modules/cli/src/main/scala/scala/cli/commands/test/Test.scala index c1d528a530..a236a5a93c 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/test/Test.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/test/Test.scala @@ -37,7 +37,7 @@ object Test extends ScalaCommand[TestOptions] { override def buildOptions(opts: TestOptions): Option[BuildOptions] = Some { import opts.* - val baseOptions = shared.buildOptions().orExit(opts.shared.logger) + val baseOptions = shared.buildOptions(watchOptions = watch).orExit(opts.shared.logger) baseOptions.copy( javaOptions = baseOptions.javaOptions.copy( javaOpts = diff --git a/modules/directives/src/main/scala/scala/build/preprocessing/directives/Watching.scala b/modules/directives/src/main/scala/scala/build/preprocessing/directives/Watching.scala new file mode 100644 index 0000000000..dbfeb6603c --- /dev/null +++ b/modules/directives/src/main/scala/scala/build/preprocessing/directives/Watching.scala @@ -0,0 +1,48 @@ +package scala.build.preprocessing.directives + +import scala.build.Positioned +import scala.build.directives.* +import scala.build.errors.BuildException +import scala.build.options.{BuildOptions, WatchOptions} +import scala.cli.commands.SpecificationLevel + +@DirectiveGroupName("Watch additional inputs") +@DirectiveExamples("//> using watching ./data") +@DirectiveUsage( + """//> using watching _path_ + | + |//> using watching _path1_ _path2_ …""".stripMargin, + """`//> using watching` _path_ + | + |`//> using watching` _path1_ _path2_ … + | + |""".stripMargin +) +@DirectiveDescription("Watch additional files or directories when using watch mode") +@DirectiveLevel(SpecificationLevel.EXPERIMENTAL) +final case class Watching( + watching: DirectiveValueParser.WithScopePath[List[Positioned[String]]] = + DirectiveValueParser.WithScopePath.empty(Nil) +) extends HasBuildOptions { + def buildOptions: Either[BuildException, BuildOptions] = + Watching.buildOptions(watching) +} + +object Watching { + val handler: DirectiveHandler[Watching] = DirectiveHandler.derive + + def buildOptions( + watching: DirectiveValueParser.WithScopePath[List[Positioned[String]]] + ): Either[BuildException, BuildOptions] = Right { + val paths = watching.value.map(_.value) + val (_, rootOpt) = Directive.osRootResource(watching.scopePath) + val resolvedPaths = rootOpt.toList.flatMap { root => + paths.map(os.Path(_, root)) + } + BuildOptions( + watchOptions = WatchOptions( + extraWatchPaths = resolvedPaths + ) + ) + } +} diff --git a/modules/integration/src/test/scala/scala/cli/integration/CompileTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/CompileTestDefinitions.scala index 050c22e15e..2c7f777471 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/CompileTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/CompileTestDefinitions.scala @@ -4,7 +4,9 @@ import com.eed3si9n.expecty.Expecty.expect import java.io.File +import scala.cli.integration.TestUtil.ProcOps import scala.cli.integration.util.BloopUtil +import scala.concurrent.duration.DurationInt import scala.util.Properties abstract class CompileTestDefinitions @@ -907,4 +909,43 @@ abstract class CompileTestDefinitions ) } } + + if (!Properties.isMac || !TestUtil.isCI) + test("--watching with --watch re-compiles on external file change") { + val sourceFile = os.rel / "Main.scala" + val externalFile = os.rel / "data" / "input.txt" + TestInputs( + sourceFile -> + """object Main { + | def value = 1 + |} + |""".stripMargin, + externalFile -> "Hello" + ).fromRoot { root => + TestUtil.withProcessWatching( + proc = os.proc( + TestUtil.cli, + "--power", + "compile", + ".", + "--watch", + "--watching", + "data", + extraOptions + ) + .spawn(cwd = root, stderr = os.Pipe), + timeout = 120.seconds + ) { (proc, timeout, ec) => + implicit val ec0 = ec + val initialOutput = proc.readStderrUntilWatchingMessage(timeout) + expect(initialOutput.exists(_.contains("Compiled"))) + + Thread.sleep(2000L) + os.write.over(root / externalFile, "World") + + val rerunOutput = proc.readStderrUntilWatchingMessage(timeout) + expect(rerunOutput.nonEmpty) + } + } + } } diff --git a/modules/integration/src/test/scala/scala/cli/integration/PackageTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/PackageTestDefinitions.scala index 3be1068114..784a94c356 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/PackageTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/PackageTestDefinitions.scala @@ -9,6 +9,7 @@ import java.util import java.util.zip.ZipFile import scala.cli.integration.TestUtil.* +import scala.concurrent.duration.DurationInt import scala.jdk.CollectionConverters.* import scala.util.{Properties, Using} @@ -1559,4 +1560,45 @@ abstract class PackageTestDefinitions extends ScalaCliSuite with TestScalaVersio expect(res.out.trim().contains(s"$moduleName.js")) } } + + if (!Properties.isMac || !TestUtil.isCI) + test("--watching with --watch re-packages on external file change") { + val sourceFile = os.rel / "Main.scala" + val externalFile = os.rel / "data" / "input.txt" + TestInputs( + sourceFile -> + """object Main extends App { + | println("Hello") + |} + |""".stripMargin, + externalFile -> "Hello" + ).fromRoot { root => + TestUtil.withProcessWatching( + proc = os.proc( + TestUtil.cli, + "--power", + "package", + ".", + "--watch", + "--watching", + "data", + "-o", + "app", + extraOptions + ) + .spawn(cwd = root, mergeErrIntoOut = true), + timeout = 120.seconds + ) { (proc, timeout, ec) => + implicit val ec0 = ec + val initialOutput = proc.readOutputUntilWatchingMessage(timeout) + expect(initialOutput.exists(_.contains("Wrote"))) + + Thread.sleep(2000L) + os.write.over(root / externalFile, "World") + + val rerunOutput = proc.readOutputUntilWatchingMessage(timeout) + expect(rerunOutput.nonEmpty) + } + } + } } diff --git a/modules/integration/src/test/scala/scala/cli/integration/RunWithWatchTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/RunWithWatchTestDefinitions.scala index afdeb5a67a..2a125f96c0 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/RunWithWatchTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/RunWithWatchTestDefinitions.scala @@ -77,6 +77,135 @@ trait RunWithWatchTestDefinitions { this: RunTestDefinitions => } } + if (!Properties.isMac || !TestUtil.isCI) { + test("--watching with CLI option triggers re-run on external file change") { + val sourceFile = os.rel / "app.scala" + val externalFile = os.rel / "data" / "input.txt" + val code = + """object App { + | def main(args: Array[String]): Unit = { + | val content = scala.io.Source.fromFile("data/input.txt").mkString.trim + | println(content) + | } + |} + |""".stripMargin + + TestInputs( + sourceFile -> code, + externalFile -> "Hello" + ).fromRoot { root => + TestUtil.withProcessWatching( + proc = os.proc( + TestUtil.cli, + "--power", + "run", + ".", + "--watch", + "--watching", + "data", + extraOptions + ) + .spawn(cwd = root, stderr = os.Pipe), + timeout = 120.seconds + ) { (proc, timeout, ec) => + val output1 = TestUtil.readLine(proc.stdout, ec, timeout) + expect(output1 == "Hello") + proc.printStderrUntilRerun(timeout)(ec) + Thread.sleep(2000L) + os.write.over(root / externalFile, "World") + val output2 = TestUtil.readLine(proc.stdout, ec, timeout) + expect(output2 == "World") + } + } + } + + test("//> using watching directive triggers re-run on external file change") { + val sourceFile = os.rel / "app.scala" + val externalFile = os.rel / "data" / "input.txt" + val code = + """//> using watching ./data + |object App { + | def main(args: Array[String]): Unit = { + | val content = scala.io.Source.fromFile("data/input.txt").mkString.trim + | println(content) + | } + |} + |""".stripMargin + + TestInputs( + sourceFile -> code, + externalFile -> "Hello" + ).fromRoot { root => + TestUtil.withProcessWatching( + proc = os.proc(TestUtil.cli, "--power", "run", ".", "--watch", extraOptions) + .spawn(cwd = root, stderr = os.Pipe), + timeout = 120.seconds + ) { (proc, timeout, ec) => + val output1 = TestUtil.readLine(proc.stdout, ec, timeout) + expect(output1 == "Hello") + proc.printStderrUntilRerun(timeout)(ec) + Thread.sleep(2000L) + os.write.over(root / externalFile, "World") + val output2 = TestUtil.readLine(proc.stdout, ec, timeout) + expect(output2 == "World") + } + } + } + + test("--watching CLI + //> using watching directive union") { + val sourceFile = os.rel / "app.scala" + val directiveWatchFile = os.rel / "data1" / "input1.txt" + val cliWatchFile = os.rel / "data2" / "input2.txt" + val code = + """//> using watching ./data1 + |object App { + | def main(args: Array[String]): Unit = { + | val fromDirective = scala.io.Source.fromFile("data1/input1.txt").mkString.trim + | val fromCli = scala.io.Source.fromFile("data2/input2.txt").mkString.trim + | println(s"$fromDirective|$fromCli") + | } + |} + |""".stripMargin + + TestInputs( + sourceFile -> code, + directiveWatchFile -> "Hello", + cliWatchFile -> "World" + ).fromRoot { root => + TestUtil.withProcessWatching( + proc = + os.proc( + TestUtil.cli, + "--power", + "run", + ".", + "--watch", + "--watching", + "data2", + extraOptions + ) + .spawn(cwd = root, stderr = os.Pipe), + timeout = 120.seconds + ) { (proc, timeout, ec) => + val output1 = TestUtil.readLine(proc.stdout, ec, timeout) + expect(output1 == "Hello|World") + + proc.printStderrUntilRerun(timeout)(ec) + Thread.sleep(2000L) + os.write.over(root / directiveWatchFile, "Bonjour") + val output2 = TestUtil.readLine(proc.stdout, ec, timeout) + expect(output2 == "Bonjour|World") + + proc.printStderrUntilRerun(timeout)(ec) + Thread.sleep(2000L) + os.write.over(root / cliWatchFile, "Universe") + val output3 = TestUtil.readLine(proc.stdout, ec, timeout) + expect(output3 == "Bonjour|Universe") + } + } + } + } + for { (platformDescription, platformOpts) <- Seq( "JVM" -> Nil, diff --git a/modules/integration/src/test/scala/scala/cli/integration/TestTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/TestTestDefinitions.scala index 2e3bdd6f55..363fd3ed1a 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/TestTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/TestTestDefinitions.scala @@ -4,7 +4,9 @@ import com.eed3si9n.expecty.Expecty.expect import scala.annotation.tailrec import scala.cli.integration.Constants.munitVersion -import scala.cli.integration.TestUtil.StringOps +import scala.cli.integration.TestUtil.{ProcOps, StringOps} +import scala.concurrent.duration.DurationInt +import scala.util.Properties abstract class TestTestDefinitions extends ScalaCliSuite with TestScalaVersionArgs { this: TestScalaVersion => @@ -232,6 +234,51 @@ abstract class TestTestDefinitions extends ScalaCliSuite with TestScalaVersionAr } } + if (!Properties.isMac || !TestUtil.isCI) + test("--watching with --watch re-runs tests on external file change") { + val sourceFile = os.rel / "MyTests.test.scala" + val externalFile = os.rel / "data" / "input.txt" + TestInputs( + sourceFile -> + s"""//> using dep org.scalameta::munit::$munitVersion + | + |class MyTests extends munit.FunSuite { + | test("watched input") { + | val content = scala.io.Source.fromFile("data/input.txt").mkString.trim + | println(content) + | assert(content.nonEmpty) + | } + |} + |""".stripMargin, + externalFile -> "Hello" + ).fromRoot { root => + TestUtil.withProcessWatching( + proc = os.proc( + TestUtil.cli, + "--power", + "test", + ".", + "--watch", + "--watching", + "data", + extraOptions + ) + .spawn(cwd = root, mergeErrIntoOut = true), + timeout = 120.seconds + ) { (proc, timeout, ec) => + implicit val ec0 = ec + val initialOutput = proc.readOutputUntilWatchingMessage(timeout) + expect(initialOutput.exists(_.contains("Hello"))) + + Thread.sleep(2000L) + os.write.over(root / externalFile, "World") + + val rerunOutput = proc.readOutputUntilWatchingMessage(timeout) + expect(rerunOutput.exists(_.contains("World"))) + } + } + } + if (actualScalaVersion.startsWith("2")) test("successful test JVM 8") { successfulTestInputs().fromRoot { root => diff --git a/modules/integration/src/test/scala/scala/cli/integration/TestUtil.scala b/modules/integration/src/test/scala/scala/cli/integration/TestUtil.scala index d8bcc49a03..3153a629dc 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/TestUtil.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/TestUtil.scala @@ -408,6 +408,26 @@ object TestUtil { while (!revertTriggered()) Thread.sleep(100L) } + def readLinesUntil( + stream: os.SubProcess.OutputStream, + ec: ExecutionContext, + timeout: Duration + )(condition: String => Boolean): Seq[String] = { + val lines = scala.collection.mutable.ListBuffer.empty[String] + var done = false + while (!done) { + val line = TestUtil.readLine(stream, ec, timeout) + if (line == null) done = true + else { + lines += line + done = condition(line) + } + } + lines.toSeq + } + + private val watchingSourcesCondition: String => Boolean = _.contains("Watching sources") + implicit class ProcOps(proc: os.SubProcess) { def printStderrUntilJlineRevertsToDumbTerminal(proc: os.SubProcess)( f: String => Unit @@ -416,6 +436,16 @@ object TestUtil { def printStderrUntilRerun(timeout: Duration)(implicit ec: ExecutionContext): Unit = TestUtil.printStderrUntilCondition(proc, timeout)(_.contains("re-run"))() + + def readStderrUntilWatchingMessage(timeout: Duration)(implicit + ec: ExecutionContext + ): Seq[String] = + TestUtil.readLinesUntil(proc.stderr, ec, timeout)(watchingSourcesCondition) + + def readOutputUntilWatchingMessage(timeout: Duration)(implicit + ec: ExecutionContext + ): Seq[String] = + TestUtil.readLinesUntil(proc.stdout, ec, timeout)(watchingSourcesCondition) } // based on the implementation from bloop-rifle: diff --git a/modules/options/src/main/scala/scala/build/options/BuildOptions.scala b/modules/options/src/main/scala/scala/build/options/BuildOptions.scala index 2375f28840..49b9a4cf5c 100644 --- a/modules/options/src/main/scala/scala/build/options/BuildOptions.scala +++ b/modules/options/src/main/scala/scala/build/options/BuildOptions.scala @@ -46,6 +46,7 @@ final case class BuildOptions( mainClass: Option[String] = None, testOptions: TestOptions = TestOptions(), notForBloopOptions: PostBuildOptions = PostBuildOptions(), + watchOptions: WatchOptions = WatchOptions(), sourceGeneratorOptions: SourceGeneratorOptions = SourceGeneratorOptions(), useBuildServer: Option[Boolean] = None ) { diff --git a/modules/options/src/main/scala/scala/build/options/WatchOptions.scala b/modules/options/src/main/scala/scala/build/options/WatchOptions.scala new file mode 100644 index 0000000000..cf01e3f75b --- /dev/null +++ b/modules/options/src/main/scala/scala/build/options/WatchOptions.scala @@ -0,0 +1,10 @@ +package scala.build.options + +final case class WatchOptions( + extraWatchPaths: Seq[os.Path] = Nil +) + +object WatchOptions { + implicit val hasHashData: HasHashData[WatchOptions] = HasHashData.nop + implicit val monoid: ConfigMonoid[WatchOptions] = ConfigMonoid.derive +} diff --git a/website/docs/commands/compile.md b/website/docs/commands/compile.md index 503d71d7fb..c480725366 100644 --- a/website/docs/commands/compile.md +++ b/website/docs/commands/compile.md @@ -64,6 +64,22 @@ Watching sources, press Ctrl+C to exit. +### Watching additional paths + +Use `--watching` to re-trigger compilation when files outside your Scala sources change: + +```bash ignore +scala-cli compile --watch --watching ./data Hello.scala +``` + +You can also configure this from sources with: + +```scala +//> using watching ./data +``` + +If you use both, Scala CLI watches every path from both the command line and the directive. + ## Scala version Scala CLI uses the latest stable version of Scala which was tested in Scala CLI (see our list diff --git a/website/docs/commands/package.md b/website/docs/commands/package.md index 0320371109..906cf480a8 100644 --- a/website/docs/commands/package.md +++ b/website/docs/commands/package.md @@ -56,6 +56,28 @@ Hello Hello --> +## Watch mode + +Use `--watch` to rebuild the package whenever sources change: + +```bash ignore +scala-cli --power package --watch Hello.scala -o hello +``` + +You can watch additional inputs too: + +```bash ignore +scala-cli --power package --watch --watching ./data Hello.scala -o hello +``` + +This also works from sources: + +```scala +//> using watching ./data +``` + +When both are present, Scala CLI watches all of the configured paths. + ## Library JARs *Library JARs* are suitable if you plan to put the resulting JAR in a class path, rather than running it as is. diff --git a/website/docs/commands/repl.md b/website/docs/commands/repl.md index 98df8b4675..650cd3ef37 100644 --- a/website/docs/commands/repl.md +++ b/website/docs/commands/repl.md @@ -80,6 +80,26 @@ scala> :quit +## Watch mode + +Use `--watch` to recompile your inputs and restart the REPL session when sources change: + +```bash ignore +scala-cli repl --watch Main.scala +``` + +`--watching` lets you include additional files or directories: + +```bash ignore +scala-cli repl --watch --watching ./data Main.scala +``` + +You can also configure extra watched paths in sources: + +```scala +//> using watching ./data +``` + ## Passing REPL options It is also possible to manually pass REPL-specific options. It can be done in a couple ways: diff --git a/website/docs/commands/run.md b/website/docs/commands/run.md index d90e8fdb27..2c8a2c72fa 100644 --- a/website/docs/commands/run.md +++ b/website/docs/commands/run.md @@ -227,6 +227,22 @@ Watching sources while your program is running. +### Watching additional paths + +`--watching` lets you specify additional files or directories to watch while using `--watch` or `--restart`: + +```bash ignore +scala-cli run --watch --watching ./data --watching ./templates Hello.scala +``` + +You can also declare extra watched paths from your sources: + +```scala +//> using watching ./data +``` + +When both `--watching` and `//> using watching` are used, Scala CLI watches all of the specified paths. + ## Scala.js Scala.js applications can also be compiled and run with the `--js` option. diff --git a/website/docs/commands/test.md b/website/docs/commands/test.md index d5e1287429..17f50622a5 100644 --- a/website/docs/commands/test.md +++ b/website/docs/commands/test.md @@ -171,6 +171,28 @@ tests.only.BarTests: + bar --> +## Watch mode + +Use `--watch` to re-run your tests whenever sources change: + +```bash ignore +scala-cli test --watch MyTests.test.scala +``` + +`--watching` can extend that to files or directories outside your Scala sources: + +```bash ignore +scala-cli test --watch --watching ./data MyTests.test.scala +``` + +You can declare the same extra watched paths from sources: + +```scala +//> using watching ./data +``` + +If both are used, Scala CLI watches all of the configured paths. + ## Filter test case ### Munit diff --git a/website/docs/reference/cli-options.md b/website/docs/reference/cli-options.md index f945537b38..b104a3cc79 100644 --- a/website/docs/reference/cli-options.md +++ b/website/docs/reference/cli-options.md @@ -1957,6 +1957,12 @@ Aliases: `--revolver` Run the application in the background, automatically kill the process and restart if sources have been changed +### `--watching` + +Aliases: `--watching-path` + +Watch additional paths for changes (used together with --watch or --restart) + ## Internal options ### Add path options diff --git a/website/docs/reference/directives.md b/website/docs/reference/directives.md index 9a9f0456bb..8bd2d05a20 100644 --- a/website/docs/reference/directives.md +++ b/website/docs/reference/directives.md @@ -695,6 +695,19 @@ Use a toolkit as dependency (not supported in Scala 2.12), 'default' version for `//> using test.toolkit default` +### Watch additional inputs + +Watch additional files or directories when using watch mode + +`//> using watching` _path_ + +`//> using watching` _path1_ _path2_ … + + + +#### Examples +`//> using watching ./data` + ## target directives From f0fc0c3b72df99a044e6af3cc0f10add0c5bb696 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 15 Mar 2026 12:05:36 +0100 Subject: [PATCH 09/64] Bump undici from 7.18.2 to 7.24.1 in /website (#4182) Bumps [undici](https://github.com/nodejs/undici) from 7.18.2 to 7.24.1. - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v7.18.2...v7.24.1) --- updated-dependencies: - dependency-name: undici dependency-version: 7.24.1 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- website/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/website/yarn.lock b/website/yarn.lock index 52f36738dd..055193af3c 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -9273,9 +9273,9 @@ undici-types@~7.16.0: integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw== undici@^7.12.0: - version "7.18.2" - resolved "https://registry.yarnpkg.com/undici/-/undici-7.18.2.tgz#6cf724ef799a67d94fd55adf66b1e184176efcdf" - integrity sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw== + version "7.24.1" + resolved "https://registry.yarnpkg.com/undici/-/undici-7.24.1.tgz#3fd0fe40e67388860810ad3275f9a23b322de650" + integrity sha512-5xoBibbmnjlcR3jdqtY2Lnx7WbrD/tHlT01TmvqZUFVc9Q1w4+j5hbnapTqbcXITMH1ovjq/W7BkqBilHiVAaA== unicode-canonical-property-names-ecmascript@^2.0.0: version "2.0.1" From bdaa4f42199836fd6935f8b6fd2d046adf9ed3da Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Sun, 15 Mar 2026 12:55:10 +0100 Subject: [PATCH 10/64] Use targeted Java/Scala mappings with the `doc` sub-command (#4180) --- build.mill | 1 + .../scala/scala/cli/commands/doc/Doc.scala | 31 ++++++---- .../scala/cli/commands/tests/DocTests.scala | 40 +++++++++++++ .../cli/integration/DocTestDefinitions.scala | 57 +++++++++++++++++++ 4 files changed, 119 insertions(+), 10 deletions(-) create mode 100644 modules/cli/src/test/scala/cli/commands/tests/DocTests.scala diff --git a/build.mill b/build.mill index f98bd81673..509c58b757 100644 --- a/build.mill +++ b/build.mill @@ -528,6 +528,7 @@ trait Core extends ScalaCliCrossSbtModule | def minimumBloopJavaVersion = ${Java.minimumBloopJava} | def minimumInternalJavaVersion = ${Java.minimumInternalJava} | def defaultJavaVersion = ${Java.defaultJava} + | def mainJavaVersions = Seq(${Java.mainJavaVersions.sorted.mkString(", ")}) | | def defaultScalaVersion = "${Scala.defaultUser}" | def defaultScala212Version = "${Scala.scala212}" diff --git a/modules/cli/src/main/scala/scala/cli/commands/doc/Doc.scala b/modules/cli/src/main/scala/scala/cli/commands/doc/Doc.scala index 521c53c72a..58bd0852e1 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/doc/Doc.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/doc/Doc.scala @@ -125,16 +125,26 @@ object Doc extends ScalaCommand[DocOptions] { logger.message(s"Wrote Scaladoc to $printableOutput") } + private def javadocBaseUrl(javaVersion: Int): String = + if javaVersion >= 11 then + s"https://docs.oracle.com/en/java/javase/$javaVersion/docs/api/java.base/" + else + s"https://docs.oracle.com/javase/$javaVersion/docs/api/" + + private def scaladocBaseUrl(scalaVersion: String): String = + s"https://scala-lang.org/api/$scalaVersion/" + // from https://github.com/VirtusLab/scala-cli/pull/103/files#diff-1039b442cbd23f605a61fdb9c3620b600aa4af6cab757932a719c54235d8e402R60 - private def defaultScaladocArgs = Seq( - "-snippet-compiler:compile", - "-Ygenerate-inkuire", - "-external-mappings:" + - ".*/scala/.*::scaladoc3::https://scala-lang.org/api/3.x/," + - ".*/java/.*::javadoc::https://docs.oracle.com/javase/8/docs/api/", - "-author", - "-groups" - ) + private[commands] def defaultScaladocArgs(scalaVersion: String, javaVersion: Int): Seq[String] = + Seq( + "-snippet-compiler:compile", + "-Ygenerate-inkuire", + "-external-mappings:" + + s".*/scala/.*::scaladoc3::${scaladocBaseUrl(scalaVersion)}," + + s".*/java/.*::javadoc::${javadocBaseUrl(javaVersion)}", + "-author", + "-groups" + ) def generateScaladocDirPath( builds: Seq[Build.Successful], @@ -171,10 +181,11 @@ object Doc extends ScalaCommand[DocOptions] { "-d", destDir.toString ) + val javaVersion = builds.head.options.javaHome().value.version val defaultArgs = if builds.head.options.notForBloopOptions.packageOptions.useDefaultScaladocOptions .getOrElse(true) - then defaultScaladocArgs + then defaultScaladocArgs(scalaParams.scalaVersion, javaVersion) else Nil val args = baseArgs ++ builds.head.project.scalaCompiler.map(_.scalacOptions).getOrElse(Nil) ++ diff --git a/modules/cli/src/test/scala/cli/commands/tests/DocTests.scala b/modules/cli/src/test/scala/cli/commands/tests/DocTests.scala new file mode 100644 index 0000000000..3835125d3f --- /dev/null +++ b/modules/cli/src/test/scala/cli/commands/tests/DocTests.scala @@ -0,0 +1,40 @@ +package scala.cli.commands.tests + +import com.eed3si9n.expecty.Expecty.assert as expect + +import scala.build.internal.Constants +import scala.cli.commands.doc.Doc + +class DocTests extends munit.FunSuite { + + for (javaVersion <- Constants.mainJavaVersions) + test(s"correct external mappings for JVM $javaVersion") { + val args = Doc.defaultScaladocArgs(Constants.defaultScalaVersion, javaVersion) + val mappingsArg = args.find(_.startsWith("-external-mappings:")).get + if javaVersion >= 11 then + expect(mappingsArg.contains(s"javase/$javaVersion/docs/api/java.base/")) + else + expect(mappingsArg.contains(s"javase/$javaVersion/docs/api/")) + expect(!mappingsArg.contains("java.base/")) + expect(mappingsArg.contains(s"scala-lang.org/api/${Constants.defaultScalaVersion}/")) + } + + test(s"correct external mappings for Scala 3 LTS (${Constants.scala3Lts})") { + val args = Doc.defaultScaladocArgs(Constants.scala3Lts, Constants.defaultJavaVersion) + val mappingsArg = args.find(_.startsWith("-external-mappings:")).get + expect(mappingsArg.contains(s"scala-lang.org/api/${Constants.scala3Lts}/")) + expect( + mappingsArg.contains(s"javase/${Constants.defaultJavaVersion}/docs/api/java.base/") + ) + } + + test(s"correct external mappings for default Scala (${Constants.defaultScalaVersion})") { + val args = + Doc.defaultScaladocArgs(Constants.defaultScalaVersion, Constants.defaultJavaVersion) + val mappingsArg = args.find(_.startsWith("-external-mappings:")).get + expect(mappingsArg.contains(s"scala-lang.org/api/${Constants.defaultScalaVersion}/")) + expect( + mappingsArg.contains(s"javase/${Constants.defaultJavaVersion}/docs/api/java.base/") + ) + } +} diff --git a/modules/integration/src/test/scala/scala/cli/integration/DocTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/DocTestDefinitions.scala index f82f40bdc9..54e8ae6a5a 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/DocTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/DocTestDefinitions.scala @@ -97,4 +97,61 @@ abstract class DocTestDefinitions extends ScalaCliSuite with TestScalaVersionArg |""".stripMargin ).fromRoot(root => os.proc(TestUtil.cli, "doc", ".", extraOptions).call(cwd = root)) } + + if actualScalaVersion.startsWith("3") then + for { + javaVersion <- + if isScala38OrNewer then + Constants.allJavaVersions.filter(_ >= Constants.scala38MinJavaVersion) + else Constants.allJavaVersions + } + test(s"doc generates correct external mapping URLs for JVM $javaVersion") { + TestUtil.retryOnCi() { + val dest = os.rel / "doc-out" + val inputs = TestInputs( + os.rel / "Lib.scala" -> + """package mylib + | + |/** A wrapper around [[java.util.HashMap]] and [[scala.Option]]. */ + |class Lib: + | /** Returns a [[java.util.HashMap]]. */ + | def getMap: java.util.HashMap[String, String] = new java.util.HashMap() + | /** Returns a [[scala.Option]]. */ + | def getOpt: Option[String] = Some("hello") + |""".stripMargin + ) + inputs.fromRoot { root => + os.proc( + TestUtil.cli, + "doc", + extraOptions, + ".", + "-o", + dest, + "--jvm", + javaVersion.toString + ).call(cwd = root, stdin = os.Inherit, stdout = os.Inherit) + + val docDir = root / dest + expect(os.isDir(docDir)) + + val htmlContent = os.walk(docDir) + .filter(_.last.endsWith(".html")) + .map(os.read(_)) + .mkString + + val expectedJavadocFragment = + if javaVersion >= 11 then + s"docs.oracle.com/en/java/javase/$javaVersion/docs/api/java.base/" + else + s"docs.oracle.com/javase/$javaVersion/docs/api/" + expect(htmlContent.contains(expectedJavadocFragment)) + + if javaVersion < 11 then + expect(!htmlContent.contains("java.base/")) + + expect(htmlContent.contains(s"scala-lang.org/api/$actualScalaVersion/")) + } + } + } } From bd1b92ea399edea89d1c189c64fd98a20d03b9dc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 09:49:58 +0100 Subject: [PATCH 11/64] Bump webfactory/ssh-agent in the github-actions group (#4187) Bumps the github-actions group with 1 update: [webfactory/ssh-agent](https://github.com/webfactory/ssh-agent). Updates `webfactory/ssh-agent` from 0.9.1 to 0.10.0 - [Release notes](https://github.com/webfactory/ssh-agent/releases) - [Changelog](https://github.com/webfactory/ssh-agent/blob/master/CHANGELOG.md) - [Commits](https://github.com/webfactory/ssh-agent/compare/a6f90b1f127823b31d4d4a8d96047790581349bd...e83874834305fe9a4a2997156cb26c5de65a8555) --- updated-dependencies: - dependency-name: webfactory/ssh-agent dependency-version: 0.10.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94ae018a4a..54100be807 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1456,7 +1456,7 @@ jobs: MILL_PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} MILL_SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} MILL_SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} - - uses: webfactory/ssh-agent@a6f90b1f127823b31d4d4a8d96047790581349bd + - uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 with: ssh-private-key: | ${{ secrets.SSH_PRIVATE_KEY_SCALA_CLI }} @@ -1624,7 +1624,7 @@ jobs: - name: Display structure of downloaded files run: ls -R working-directory: artifacts/ - - uses: webfactory/ssh-agent@a6f90b1f127823b31d4d4a8d96047790581349bd + - uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 with: ssh-private-key: | ${{ secrets.SCALA_CLI_PACKAGES_KEY }} From c1d45d7a98d90c9efbb083f27f69f60207576076 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Tue, 17 Mar 2026 09:50:20 +0100 Subject: [PATCH 12/64] Correct Native test bridge error message and parse META-INF service file by lines to improve framework discovery (#4185) --- .../scala/scala/build/internal/Runner.scala | 2 +- .../build/tests/FrameworkDiscoveryTests.scala | 49 +++++++++++++++++++ .../NoFrameworkFoundByNativeBridgeError.scala | 4 ++ .../build/testrunner/AsmTestRunner.scala | 12 ++++- 4 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 modules/build/src/test/scala/scala/build/tests/FrameworkDiscoveryTests.scala create mode 100644 modules/core/src/main/scala/scala/build/errors/NoFrameworkFoundByNativeBridgeError.scala diff --git a/modules/build/src/main/scala/scala/build/internal/Runner.scala b/modules/build/src/main/scala/scala/build/internal/Runner.scala index 2309922eb2..c02df7ee8b 100644 --- a/modules/build/src/main/scala/scala/build/internal/Runner.scala +++ b/modules/build/src/main/scala/scala/build/internal/Runner.scala @@ -539,7 +539,7 @@ object Runner { |""".stripMargin ) - if finalTestFrameworks.isEmpty then Left(new NoFrameworkFoundByBridgeError) + if finalTestFrameworks.isEmpty then Left(new NoFrameworkFoundByNativeBridgeError) else runTests(classPath, finalTestFrameworks, requireTests, args, parentInspector) } finally if adapter != null then adapter.close() diff --git a/modules/build/src/test/scala/scala/build/tests/FrameworkDiscoveryTests.scala b/modules/build/src/test/scala/scala/build/tests/FrameworkDiscoveryTests.scala new file mode 100644 index 0000000000..862ed6a203 --- /dev/null +++ b/modules/build/src/test/scala/scala/build/tests/FrameworkDiscoveryTests.scala @@ -0,0 +1,49 @@ +package scala.build.tests + +import java.nio.file.Files + +import scala.build.errors.NoFrameworkFoundByNativeBridgeError +import scala.build.testrunner.AsmTestRunner + +class FrameworkDiscoveryTests extends TestUtil.ScalaCliBuildSuite { + + test( + "findFrameworkServices parses Java ServiceLoader format (trim, skip comments and empty lines)" + ) { + val dir = Files.createTempDirectory("scala-cli-framework-services-") + try { + val servicesDir = dir.resolve("META-INF").resolve("services") + Files.createDirectories(servicesDir) + val serviceFile = servicesDir.resolve("sbt.testing.Framework") + // Content with newlines, comments, and surrounding whitespace + val content = + """munit.Framework + |# comment line + | + | munit.native.Framework + | + |""".stripMargin + Files.writeString(serviceFile, content) + + val found = AsmTestRunner.findFrameworkServices(Seq(dir)) + assertEquals( + found.sorted, + Seq("munit.Framework", "munit.native.Framework"), + clue = "Service file lines should be trimmed; comments and empty lines skipped" + ) + } + finally { + def deleteRecursively(p: java.nio.file.Path): Unit = { + if Files.isDirectory(p) then Files.list(p).forEach(deleteRecursively) + Files.deleteIfExists(p) + } + deleteRecursively(dir) + } + } + + test("NoFrameworkFoundByNativeBridgeError has Native-specific message (not Scala.js)") { + val err = new NoFrameworkFoundByNativeBridgeError + assert(err.getMessage.contains("Scala Native"), clue = "Message should mention Scala Native") + assert(!err.getMessage.contains("Scala.js"), clue = "Message should not mention Scala.js") + } +} diff --git a/modules/core/src/main/scala/scala/build/errors/NoFrameworkFoundByNativeBridgeError.scala b/modules/core/src/main/scala/scala/build/errors/NoFrameworkFoundByNativeBridgeError.scala new file mode 100644 index 0000000000..343a5ff3ae --- /dev/null +++ b/modules/core/src/main/scala/scala/build/errors/NoFrameworkFoundByNativeBridgeError.scala @@ -0,0 +1,4 @@ +package scala.build.errors + +final class NoFrameworkFoundByNativeBridgeError + extends TestError("No framework found by Scala Native test bridge") diff --git a/modules/test-runner/src/main/scala/scala/build/testrunner/AsmTestRunner.scala b/modules/test-runner/src/main/scala/scala/build/testrunner/AsmTestRunner.scala index bfffee9d1a..5cd3f75e09 100644 --- a/modules/test-runner/src/main/scala/scala/build/testrunner/AsmTestRunner.scala +++ b/modules/test-runner/src/main/scala/scala/build/testrunner/AsmTestRunner.scala @@ -195,9 +195,19 @@ object AsmTestRunner { .iterator .flatMap(findInClassPath(_, name).iterator) + /** Parse Java ServiceLoader format: one class name per line; # comments and empty lines ignored. + */ + private def parseServiceFileContent(content: String): Seq[String] = + content + .split("[\r\n]+") + .iterator + .map(_.trim) + .filter(line => line.nonEmpty && !line.startsWith("#")) + .toSeq + def findFrameworkServices(classPath: Seq[Path]): Seq[String] = findInClassPath(classPath, "META-INF/services/sbt.testing.Framework") - .map(b => new String(b, StandardCharsets.UTF_8)) + .flatMap(b => parseServiceFileContent(new String(b, StandardCharsets.UTF_8))) .toSeq def findFrameworks( From ab8f4b9d41772c0338a4112e7cc6abf4fd1da944 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Tue, 17 Mar 2026 11:50:49 +0100 Subject: [PATCH 13/64] Bump `coursier` to 2.1.25-M24 (#4184) --- .github/scripts/get-latest-cs.sh | 2 +- build.mill | 2 +- mill | 2 +- project/deps/package.mill | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/scripts/get-latest-cs.sh b/.github/scripts/get-latest-cs.sh index 2072b6d0c4..7f54dbde9c 100644 --- a/.github/scripts/get-latest-cs.sh +++ b/.github/scripts/get-latest-cs.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -e -CS_VERSION="2.1.25-M23" +CS_VERSION="2.1.25-M24" DIR="$(cs get --archive "https://github.com/coursier/coursier/releases/download/v$CS_VERSION/cs-x86_64-pc-win32.zip")" diff --git a/build.mill b/build.mill index 509c58b757..83484c3460 100644 --- a/build.mill +++ b/build.mill @@ -4,7 +4,7 @@ //| - io.github.alexarchambault.mill::mill-native-image-upload:0.2.4 //| - com.goyeau::mill-scalafix::0.6.0 //| - com.lumidion::sonatype-central-client-requests:0.6.0 -//| - io.get-coursier:coursier-launcher_2.13:2.1.25-M23 +//| - io.get-coursier:coursier-launcher_2.13:2.1.25-M24 //| - org.eclipse.jgit:org.eclipse.jgit:7.5.0.202512021534-r package build diff --git a/mill b/mill index 90eb89e197..601a73c0c7 100755 --- a/mill +++ b/mill @@ -2,7 +2,7 @@ # Adapted from -coursier_version="2.1.25-M23" +coursier_version="2.1.25-M24" COMMAND=$@ # necessary for Windows various shell environments diff --git a/project/deps/package.mill b/project/deps/package.mill index e8f98b0216..3d21cca463 100644 --- a/project/deps/package.mill +++ b/project/deps/package.mill @@ -124,7 +124,7 @@ object Deps { def ammoniteForScala3Lts = ammonite def argonautShapeless = "1.3.1" // jni-utils version may need to be sync-ed when bumping the coursier version - def coursierDefault = "2.1.25-M23" + def coursierDefault = "2.1.25-M24" def coursier = coursierDefault def coursierCli = coursierDefault def coursierPublish = "0.4.4" From 4c529d6b7748a67b3e3cc13ee73b4b15b3355932 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Tue, 17 Mar 2026 12:03:16 +0100 Subject: [PATCH 14/64] Add support for `--cross` in the `doc` sub-command (#4183) --- .../scala/scala/cli/commands/doc/Doc.scala | 102 +++++++++++++++--- .../scala/cli/commands/doc/DocOptions.scala | 6 +- .../scala/cli/commands/tests/DocTests.scala | 41 +++++++ .../cli/integration/DocTestDefinitions.scala | 35 ++++++ website/docs/commands/doc.md | 51 ++++++++- website/docs/reference/cli-options.md | 2 +- website/docs/reference/commands.md | 2 +- .../reference/scala-command/cli-options.md | 2 +- .../docs/reference/scala-command/commands.md | 2 +- 9 files changed, 217 insertions(+), 26 deletions(-) diff --git a/modules/cli/src/main/scala/scala/cli/commands/doc/Doc.scala b/modules/cli/src/main/scala/scala/cli/commands/doc/Doc.scala index 58bd0852e1..ff6c36d046 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/doc/Doc.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/doc/Doc.scala @@ -9,13 +9,15 @@ import java.io.File import scala.build.* import scala.build.EitherCps.{either, value} +import scala.build.Ops.* import scala.build.compiler.{ScalaCompilerMaker, SimpleScalaCompilerMaker} -import scala.build.errors.BuildException +import scala.build.errors.{BuildException, CompositeBuildException} import scala.build.interactive.InteractiveFileOps import scala.build.internal.Runner import scala.build.options.{BuildOptions, Scope} import scala.cli.CurrentParams import scala.cli.commands.shared.{HelpCommandGroup, HelpGroup, SharedOptions} +import scala.cli.commands.util.BuildCommandHelpers import scala.cli.commands.{CommandUtils, ScalaCommand, SpecificationLevel} import scala.cli.config.Keys import scala.cli.errors.ScaladocGenerationFailedError @@ -23,7 +25,7 @@ import scala.cli.util.ArgHelpers.* import scala.cli.util.ConfigDbUtils import scala.util.Properties -object Doc extends ScalaCommand[DocOptions] { +object Doc extends ScalaCommand[DocOptions] with BuildCommandHelpers { override def group: String = HelpCommandGroup.Main.toString override def sharedOptions(options: DocOptions): Option[SharedOptions] = Some(options.shared) @@ -52,39 +54,104 @@ object Doc extends ScalaCommand[DocOptions] { configDb.get(Keys.actions).getOrElse(None) ) + val cross = options.compileCross.cross.getOrElse(false) val withTestScope = options.shared.scope.test.getOrElse(false) - Build.build( + val buildResult = Build.build( inputs, initialBuildOptions, compilerMaker, docCompilerMakerOpt, logger, - crossBuilds = false, + crossBuilds = cross, buildTests = withTestScope, partial = None, actionableDiagnostics = actionableDiagnostics ) - .orExit(logger).docBuilds match { + val docBuilds = buildResult.orExit(logger).allDoc + docBuilds match { case b if b.forall(_.success) => val successfulBuilds = b.collect { case s: Build.Successful => s } - val res0 = doDoc( - logger, - options.output.filter(_.nonEmpty), - options.force, - successfulBuilds, - args.unparsed, - withTestScope - ) - res0.orExit(logger) + if cross && successfulBuilds.nonEmpty then + doDocCrossBuilds( + logger = logger, + outputOpt = options.output.filter(_.nonEmpty), + force = options.force, + allBuilds = successfulBuilds, + extraArgs = args.unparsed, + withTestScope = withTestScope + ).orExit(logger) + else + doDoc( + logger, + options.output.filter(_.nonEmpty), + options.force, + successfulBuilds, + args.unparsed, + withTestScope + ).orExit(logger) case b if b.exists(bb => !bb.success && !bb.cancelled) => - System.err.println("Compilation failed") + logger.error("Compilation failed") sys.exit(1) case _ => - System.err.println("Build cancelled") + logger.error("Build cancelled") sys.exit(1) } } + /** Determines the output subdirectory name for one cross build when using `--cross`. Used so that + * each Scala version (and optionally platform) gets a distinct directory. + */ + def crossDocSubdirName( + crossParams: CrossBuildParams, + multipleCrossGroups: Boolean, + needsPlatformInSuffix: Boolean + ): String = + if !multipleCrossGroups then "" + else if needsPlatformInSuffix then s"${crossParams.scalaVersion}_${crossParams.platform}" + else crossParams.scalaVersion + + private def doDocCrossBuilds( + logger: Logger, + outputOpt: Option[String], + force: Boolean, + allBuilds: Seq[Build.Successful], + extraArgs: Seq[String], + withTestScope: Boolean + ): Either[BuildException, Unit] = either { + val crossBuildGroups = allBuilds.groupedByCrossParams.toSeq + val multipleCrossGroups = crossBuildGroups.size > 1 + if multipleCrossGroups then + logger.message(s"Generating documentation for ${crossBuildGroups.size} cross builds...") + val defaultName = "scala-doc" + val baseOutputPath = outputOpt.map(p => os.Path(p, Os.pwd)).getOrElse(os.pwd / defaultName) + val platforms = crossBuildGroups.map(_._1.platform).distinct + val needsPlatformInSuffix = platforms.size > 1 + value { + crossBuildGroups + .map { (crossParams, builds) => + if multipleCrossGroups then + logger.message(s"Generating documentation for ${crossParams.asString}...") + val crossSubDir = + Doc.crossDocSubdirName(crossParams, multipleCrossGroups, needsPlatformInSuffix) + val groupOutputOpt = + if crossSubDir.nonEmpty then Some((baseOutputPath / crossSubDir).toString) + else outputOpt.filter(_.nonEmpty).orElse(Some(defaultName)) + doDoc( + logger = logger, + outputOpt = groupOutputOpt, + force = force, + builds = builds, + extraArgs = extraArgs, + withTestScope = withTestScope + ) + } + .sequence + .left + .map(CompositeBuildException(_)) + .map(_ => ()) + } + } + private def doDoc( logger: Logger, outputOpt: Option[String], @@ -106,7 +173,7 @@ object Doc extends ScalaCommand[DocOptions] { builds.head.options.interactive.map { interactive => InteractiveFileOps.erasingPath(interactive, printableDest, destPath) { () => val msg = s"$printableDest already exists" - System.err.println(s"Error: $msg. Pass -f or --force to force erasing it.") + logger.error(s"$msg. Pass -f or --force to force erasing it.") sys.exit(1) } } @@ -118,6 +185,7 @@ object Doc extends ScalaCommand[DocOptions] { val docJarPath = value(generateScaladocDirPath(builds, logger, extraArgs, withTestScope)) value(alreadyExistsCheck()) + os.makeDir.all(destPath / os.up) if force then os.copy.over(docJarPath, destPath) else os.copy(docJarPath, destPath) val printableOutput = CommandUtils.printablePath(destPath) diff --git a/modules/cli/src/main/scala/scala/cli/commands/doc/DocOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/doc/DocOptions.scala index ac16c335e0..1cc5005750 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/doc/DocOptions.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/doc/DocOptions.scala @@ -4,7 +4,9 @@ import caseapp.* import caseapp.core.help.Help import scala.cli.ScalaCli.fullRunnerName -import scala.cli.commands.shared.{HasSharedOptions, HelpGroup, HelpMessages, SharedOptions} +import scala.cli.commands.shared.{ + CrossOptions, HasSharedOptions, HelpGroup, HelpMessages, SharedOptions +} import scala.cli.commands.tags // format: off @@ -12,6 +14,8 @@ import scala.cli.commands.tags final case class DocOptions( @Recurse shared: SharedOptions = SharedOptions(), + @Recurse + compileCross: CrossOptions = CrossOptions(), @Group(HelpGroup.Doc.toString) @Tag(tags.must) @HelpMessage("Set the destination path") diff --git a/modules/cli/src/test/scala/cli/commands/tests/DocTests.scala b/modules/cli/src/test/scala/cli/commands/tests/DocTests.scala index 3835125d3f..62b8fffb15 100644 --- a/modules/cli/src/test/scala/cli/commands/tests/DocTests.scala +++ b/modules/cli/src/test/scala/cli/commands/tests/DocTests.scala @@ -2,11 +2,52 @@ package scala.cli.commands.tests import com.eed3si9n.expecty.Expecty.assert as expect +import scala.build.CrossBuildParams import scala.build.internal.Constants import scala.cli.commands.doc.Doc class DocTests extends munit.FunSuite { + test("crossDocSubdirName: single cross group yields empty subdir") { + val params = CrossBuildParams(Constants.defaultScala213Version, "jvm") + expect(Doc.crossDocSubdirName( + params, + multipleCrossGroups = false, + needsPlatformInSuffix = false + ) == "") + expect(Doc.crossDocSubdirName( + params, + multipleCrossGroups = false, + needsPlatformInSuffix = true + ) == "") + } + + test("crossDocSubdirName: multiple groups, single platform uses only Scala version") { + val params = CrossBuildParams(Constants.scala3Lts, "jvm") + expect( + Doc.crossDocSubdirName(params, multipleCrossGroups = true, needsPlatformInSuffix = false) == + Constants.scala3Lts + ) + } + + test("crossDocSubdirName: multiple groups and platforms include platform in suffix") { + val paramsJvm = CrossBuildParams(Constants.defaultScala213Version, "jvm") + val paramsJs = CrossBuildParams(Constants.defaultScala213Version, "js") + val paramsNat = CrossBuildParams(Constants.scala3Lts, "native") + expect( + Doc.crossDocSubdirName(paramsJvm, multipleCrossGroups = true, needsPlatformInSuffix = true) == + s"${Constants.defaultScala213Version}_jvm" + ) + expect( + Doc.crossDocSubdirName(paramsJs, multipleCrossGroups = true, needsPlatformInSuffix = true) == + s"${Constants.defaultScala213Version}_js" + ) + expect( + Doc.crossDocSubdirName(paramsNat, multipleCrossGroups = true, needsPlatformInSuffix = true) == + s"${Constants.scala3Lts}_native" + ) + } + for (javaVersion <- Constants.mainJavaVersions) test(s"correct external mappings for JVM $javaVersion") { val args = Doc.defaultScaladocArgs(Constants.defaultScalaVersion, javaVersion) diff --git a/modules/integration/src/test/scala/scala/cli/integration/DocTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/DocTestDefinitions.scala index 54e8ae6a5a..2cfdb38fd4 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/DocTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/DocTestDefinitions.scala @@ -154,4 +154,39 @@ abstract class DocTestDefinitions extends ScalaCliSuite with TestScalaVersionArg } } } + + test(s"doc --cross with multiple Scala versions produces doc output per cross") { + val crossScalaVersions = Seq(actualScalaVersion, Constants.scala213, Constants.scala212) + val dest = os.rel / "doc-cross" + TestInputs( + os.rel / "project.scala" -> s"//> using scala ${crossScalaVersions.mkString(" ")}", + os.rel / "Lib.scala" -> + """package mylib + | + |/** A sample class. */ + |class Lib { + | def value: Int = 42 + |} + |""".stripMargin + ).fromRoot { root => + os.proc( + TestUtil.cli, + "doc", + "--cross", + "--power", + extraOptions, + ".", + "-o", + dest + ).call(cwd = root, stdin = os.Inherit, stdout = os.Inherit) + + val baseDocPath = root / dest + expect(os.isDir(baseDocPath)) + crossScalaVersions.foreach { version => + val subDir = baseDocPath / version + expect(os.isDir(subDir)) + expect(os.list(subDir).exists(_.last.endsWith(".html"))) + } + } + } } diff --git a/website/docs/commands/doc.md b/website/docs/commands/doc.md index c05b50a53e..f630ade249 100644 --- a/website/docs/commands/doc.md +++ b/website/docs/commands/doc.md @@ -3,18 +3,20 @@ title: Doc sidebar_position: 18 --- -Scala CLI can generate the API documentation of your Scala 2, Scala 3, and Java projects. It provides features similar to `javadoc`. +Scala CLI can generate the API documentation of your Scala 2, Scala 3, and Java projects. It provides features similar +to `javadoc`. The API documentation is generated in a directory whose files make up a static website: ```scala title=Hello.scala package hello + /** Hello object for running main method */ object Hello { /** - * Main method - * @param args The command line arguments. - **/ + * Main method + * @param args The command line arguments. + * */ def main(args: Array[String]): Unit = println("Hello") } @@ -31,6 +33,47 @@ Wrote Scaladoc to ./scala-doc The output directory `scala-doc` contains the static site files with your documentation. +## Cross-building documentation ⚡️ + +:::caution +The `--cross` option is experimental and requires setting the `--power` option to be used. +You can pass it explicitly or set it globally by running: + + scala-cli config power true + +::: + +Use `--cross` (with `--power`) to build and generate Scaladoc for **every** Scala version and platform combination +configured for your project—the same behavior as `run` and `package` with `--cross`. This is useful when you have +multiple Scala versions or platforms and want documentation for each. + +Example: a library that supports both Scala 2.13 and 3.3 LTS: + +```scala title=Example.scala +//> using scala 2.13 3.3.7 +package lib + +/** Example class for cross-built documentation. */ +class Example { + /** Returns a greeting. */ + def greet: String = "Hello" +} +``` + +When `--cross` produces multiple cross builds, the output directory is split into one subdirectory per combination: by +default a subdirectory per Scala version (e.g. `doc-out/2.13.18`, `doc-out/3.3.7`), and when targeting multiple +platforms, each subdirectory name includes the platform (e.g. `doc-out/3.3.7_jvm`). This avoids overwriting docs from +different builds. + +```bash +scala-cli --power doc --cross . -o doc-out +# Wrote Scaladoc to doc-out/2.13.18 +# Wrote Scaladoc to doc-out/3.3.7 +``` + +Without `--cross`, only a single build (the default Scala version and platform) is documented and written to the given +output path. + After opening the generated static documentation (you have to open `scala-doc/index.html` in your browser), you will see the generated scaladoc documentation. The following screen shows the definition of the `main` method: diff --git a/website/docs/reference/cli-options.md b/website/docs/reference/cli-options.md index b104a3cc79..5a764875ef 100644 --- a/website/docs/reference/cli-options.md +++ b/website/docs/reference/cli-options.md @@ -246,7 +246,7 @@ Disable using the network to download artifacts, use the local cache only Available in commands: -[`compile`](./commands.md#compile), [`package`](./commands.md#package), [`publish`](./commands.md#publish), [`publish local`](./commands.md#publish-local), [`repl` , `console`](./commands.md#repl), [`run`](./commands.md#run), [`shebang`](./commands.md#shebang), [`test`](./commands.md#test) +[`compile`](./commands.md#compile), [`doc`](./commands.md#doc), [`package`](./commands.md#package), [`publish`](./commands.md#publish), [`publish local`](./commands.md#publish-local), [`repl` , `console`](./commands.md#repl), [`run`](./commands.md#run), [`shebang`](./commands.md#shebang), [`test`](./commands.md#test) diff --git a/website/docs/reference/commands.md b/website/docs/reference/commands.md index 8b5b788835..3b375f4054 100644 --- a/website/docs/reference/commands.md +++ b/website/docs/reference/commands.md @@ -95,7 +95,7 @@ All supported types of inputs can be mixed with each other. For detailed documentation refer to our website: https://scala-cli.virtuslab.org/docs/commands/doc -Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [doc](./cli-options.md#doc-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [workspace](./cli-options.md#workspace-options) +Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [cross](./cli-options.md#cross-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [doc](./cli-options.md#doc-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [workspace](./cli-options.md#workspace-options) ## export diff --git a/website/docs/reference/scala-command/cli-options.md b/website/docs/reference/scala-command/cli-options.md index 5c7e54b124..2c2f7e14e4 100644 --- a/website/docs/reference/scala-command/cli-options.md +++ b/website/docs/reference/scala-command/cli-options.md @@ -200,7 +200,7 @@ Force overwriting values for key Available in commands: -[`compile`](./commands.md#compile), [`repl` , `console`](./commands.md#repl), [`run`](./commands.md#run), [`shebang`](./commands.md#shebang), [`test`](./commands.md#test) +[`compile`](./commands.md#compile), [`doc`](./commands.md#doc), [`repl` , `console`](./commands.md#repl), [`run`](./commands.md#run), [`shebang`](./commands.md#shebang), [`test`](./commands.md#test) diff --git a/website/docs/reference/scala-command/commands.md b/website/docs/reference/scala-command/commands.md index 9d190bca31..0d8583fa93 100644 --- a/website/docs/reference/scala-command/commands.md +++ b/website/docs/reference/scala-command/commands.md @@ -88,7 +88,7 @@ All supported types of inputs can be mixed with each other. For detailed documentation refer to our website: https://scala-cli.virtuslab.org/docs/commands/doc -Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [doc](./cli-options.md#doc-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [workspace](./cli-options.md#workspace-options) +Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [cross](./cli-options.md#cross-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [doc](./cli-options.md#doc-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [workspace](./cli-options.md#workspace-options) ### repl From bc4f0b06826ac77e6d7eef2bcd01fe4fffccada4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 13:39:57 +0100 Subject: [PATCH 15/64] Bump sass in /website in the npm-dependencies group (#4188) Bumps the npm-dependencies group in /website with 1 update: [sass](https://github.com/sass/dart-sass). Updates `sass` from 1.97.3 to 1.98.0 - [Release notes](https://github.com/sass/dart-sass/releases) - [Changelog](https://github.com/sass/dart-sass/blob/main/CHANGELOG.md) - [Commits](https://github.com/sass/dart-sass/compare/1.97.3...1.98.0) --- updated-dependencies: - dependency-name: sass dependency-version: 1.98.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-dependencies ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- website/package.json | 2 +- website/yarn.lock | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/website/package.json b/website/package.json index a4f98cc740..f47853b8d4 100644 --- a/website/package.json +++ b/website/package.json @@ -28,7 +28,7 @@ "react-dom": "^19.2.4", "react-loadable": "^5.5.0", "react-player": "^3.4.0", - "sass": "^1.97.3", + "sass": "^1.98.0", "search-insights": "^2.17.3", "@svta/cml-cta": "1.0.5", "@svta/cml-structured-field-values": "1.1.2", diff --git a/website/yarn.lock b/website/yarn.lock index 055193af3c..8b7e1fbe4e 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -5631,7 +5631,7 @@ immediate@~3.0.5: resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== -immutable@^5.0.2: +immutable@^5.1.5: version "5.1.5" resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.1.5.tgz#93ee4db5c2a9ab42a4a783069f3c5d8847d40165" integrity sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A== @@ -8550,13 +8550,13 @@ sass-loader@^16.0.2: dependencies: neo-async "^2.6.2" -sass@^1.97.3: - version "1.97.3" - resolved "https://registry.yarnpkg.com/sass/-/sass-1.97.3.tgz#9cb59339514fa7e2aec592b9700953ac6e331ab2" - integrity sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg== +sass@^1.98.0: + version "1.98.0" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.98.0.tgz#924ce85a3745ccaccd976262fdc1bc0c13aa8e57" + integrity sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A== dependencies: chokidar "^4.0.0" - immutable "^5.0.2" + immutable "^5.1.5" source-map-js ">=0.6.2 <2.0.0" optionalDependencies: "@parcel/watcher" "^2.4.1" From 7df6c78a4b27663b12ea0938823c355c4b9ff6c3 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Tue, 17 Mar 2026 19:16:27 +0100 Subject: [PATCH 16/64] Warn when `.java` & `.scala` sources are used in a mixed compilation with disabled build server (#4181) --- .../src/main/scala/scala/build/Build.scala | 18 +++++ .../scala/build/tests/BuildTestsScalac.scala | 31 ++++++++- .../scala/scala/build/tests/TestInputs.scala | 14 ++-- .../scala/scala/build/tests/TestLogger.scala | 35 ++++++++++ .../integration/CompileTestDefinitions.scala | 66 +++++++++++++------ 5 files changed, 137 insertions(+), 27 deletions(-) diff --git a/modules/build/src/main/scala/scala/build/Build.scala b/modules/build/src/main/scala/scala/build/Build.scala index 89377a0daa..04816c8299 100644 --- a/modules/build/src/main/scala/scala/build/Build.scala +++ b/modules/build/src/main/scala/scala/build/Build.scala @@ -1199,6 +1199,24 @@ object Build { ) } + if sources.hasJava && sources.hasScala && options.useBuildServer.contains(false) then { + val javaPaths = sources.paths + .filter(_._1.last.endsWith(".java")) + .map(_._1.toString) ++ + sources.inMemory + .filter(_.generatedRelPath.last.endsWith(".java")) + .map(_.originalPath.fold(identity, _._2.toString)) + val javaPathsList = + javaPaths.map(p => s" $p").mkString(System.lineSeparator()) + logger.message( + s"""$warnPrefix With ${Console.BOLD}--server=false${Console.RESET}, .java files are not compiled to .class files. + |scalac parses .java sources for type information (cross-compilation), but without the build server (Bloop/Zinc) nothing compiles them to bytecode. + |Affected .java files: + |$javaPathsList + |Remove --server=false or compile Java files separately to avoid runtime NoClassDefFoundError.""".stripMargin + ) + } + buildClient.clear() buildClient.setGeneratedSources(scope, generatedSources) diff --git a/modules/build/src/test/scala/scala/build/tests/BuildTestsScalac.scala b/modules/build/src/test/scala/scala/build/tests/BuildTestsScalac.scala index a8052a6303..7d32c6f614 100644 --- a/modules/build/src/test/scala/scala/build/tests/BuildTestsScalac.scala +++ b/modules/build/src/test/scala/scala/build/tests/BuildTestsScalac.scala @@ -1,3 +1,32 @@ package scala.build.tests -class BuildTestsScalac extends BuildTests(server = false) +class BuildTestsScalac extends BuildTests(server = false) { + + test("warn about Java files in mixed compilation with --server=false") { + val recordingLogger = new RecordingLogger() + val inputs = TestInputs( + os.rel / "Side.java" -> + """public class Side { + | public static String message = "Hello"; + |} + |""".stripMargin, + os.rel / "Main.scala" -> + """@main def main() = println(Side.message) + |""".stripMargin + ) + val options = defaultScala3Options.copy(useBuildServer = Some(false)) + inputs.withBuild(options, buildThreads, bloopConfigOpt, logger = Some(recordingLogger)) { + (_, _, maybeBuild) => + assert(maybeBuild.isRight) + val hasWarning = recordingLogger.messages.exists { msg => + msg.contains(".java files are not compiled to .class files") && + msg.contains("--server=false") && + msg.contains("Affected .java files") + } + assert( + hasWarning, + s"Expected warning about Java files with --server=false in: ${recordingLogger.messages.mkString("\n")}" + ) + } + } +} diff --git a/modules/build/src/test/scala/scala/build/tests/TestInputs.scala b/modules/build/src/test/scala/scala/build/tests/TestInputs.scala index d6b34a6df2..45afb7f5fb 100644 --- a/modules/build/src/test/scala/scala/build/tests/TestInputs.scala +++ b/modules/build/src/test/scala/scala/build/tests/TestInputs.scala @@ -8,7 +8,7 @@ import scala.build.compiler.{BloopCompilerMaker, SimpleScalaCompilerMaker} import scala.build.errors.BuildException import scala.build.input.{Inputs, ScalaCliInvokeData} import scala.build.options.{BuildOptions, Scope} -import scala.build.{Build, BuildThreads, Builds} +import scala.build.{Build, BuildThreads, Builds, Logger} import scala.util.Try import scala.util.control.NonFatal @@ -94,7 +94,8 @@ final case class TestInputs( fromDirectory: Boolean = false, buildTests: Boolean = true, actionableDiagnostics: Boolean = false, - skipCreatingSources: Boolean = false + skipCreatingSources: Boolean = false, + logger: Option[Logger] = None )(f: (os.Path, Inputs, Either[BuildException, Builds]) => T): T = withCustomInputs(fromDirectory, None, skipCreatingSources) { (root, inputs) => val compilerMaker = bloopConfigOpt match { @@ -108,13 +109,14 @@ final case class TestInputs( case None => SimpleScalaCompilerMaker("java", Nil) } + val log = logger.getOrElse(TestLogger()) val builds = Build.build( inputs, options, compilerMaker, None, - TestLogger(), + log, crossBuilds = false, buildTests = buildTests, partial = None, @@ -131,7 +133,8 @@ final case class TestInputs( buildTests: Boolean = true, actionableDiagnostics: Boolean = false, scope: Scope = Scope.Main, - skipCreatingSources: Boolean = false + skipCreatingSources: Boolean = false, + logger: Option[Logger] = None )(f: (os.Path, Inputs, Either[BuildException, Build]) => T): T = withBuilds( options, @@ -140,7 +143,8 @@ final case class TestInputs( fromDirectory, buildTests = buildTests, actionableDiagnostics = actionableDiagnostics, - skipCreatingSources = skipCreatingSources + skipCreatingSources = skipCreatingSources, + logger = logger ) { (p, i, builds) => f( diff --git a/modules/build/src/test/scala/scala/build/tests/TestLogger.scala b/modules/build/src/test/scala/scala/build/tests/TestLogger.scala index 35de6eed7e..bd6841d75d 100644 --- a/modules/build/src/test/scala/scala/build/tests/TestLogger.scala +++ b/modules/build/src/test/scala/scala/build/tests/TestLogger.scala @@ -10,8 +10,43 @@ import java.io.PrintStream import scala.build.Logger import scala.build.errors.{BuildException, Diagnostic} import scala.build.internals.FeatureType +import scala.collection.mutable.ListBuffer import scala.scalanative.build as sn +/** Logger that records all message() and log() calls for test assertions. */ +final class RecordingLogger(delegate: Logger = TestLogger()) extends Logger { + val messages: ListBuffer[String] = ListBuffer.empty + + override def error(message: String): Unit = delegate.error(message) + override def message(message: => String): Unit = { + val msg = message + messages += msg + delegate.message(msg) + } + override def log(s: => String): Unit = { + val msg = s + messages += msg + delegate.log(msg) + } + override def log(s: => String, debug: => String): Unit = delegate.log(s, debug) + override def debug(s: => String): Unit = delegate.debug(s) + override def log(diagnostics: Seq[Diagnostic]): Unit = delegate.log(diagnostics) + override def log(ex: BuildException): Unit = delegate.log(ex) + override def debug(ex: BuildException): Unit = delegate.debug(ex) + override def exit(ex: BuildException): Nothing = delegate.exit(ex) + override def coursierLogger(message: String): CacheLogger = delegate.coursierLogger(message) + override def bloopRifleLogger: BloopRifleLogger = delegate.bloopRifleLogger + override def scalaJsLogger: ScalaJsLogger = delegate.scalaJsLogger + override def scalaNativeTestLogger: sn.Logger = delegate.scalaNativeTestLogger + override def scalaNativeCliInternalLoggerOptions: List[String] = + delegate.scalaNativeCliInternalLoggerOptions + override def compilerOutputStream: PrintStream = delegate.compilerOutputStream + override def verbosity: Int = delegate.verbosity + override def experimentalWarning(featureName: String, featureType: FeatureType): Unit = + delegate.experimentalWarning(featureName, featureType) + override def flushExperimentalWarnings: Unit = delegate.flushExperimentalWarnings +} + case class TestLogger(info: Boolean = true, debug: Boolean = false) extends Logger { override def log(diagnostics: Seq[Diagnostic]): Unit = { diagnostics.foreach { d => diff --git a/modules/integration/src/test/scala/scala/cli/integration/CompileTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/CompileTestDefinitions.scala index 2c7f777471..61efd2a41f 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/CompileTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/CompileTestDefinitions.scala @@ -62,9 +62,7 @@ abstract class CompileTestDefinitions |""".stripMargin ) - test( - "java files with no using directives should not produce warnings about using directives in multiple files" - ) { + { val inputs = TestInputs( os.rel / "Bar.java" -> """public class Bar {} @@ -73,12 +71,23 @@ abstract class CompileTestDefinitions """public class Foo {} |""".stripMargin ) - - inputs.fromRoot { root => - val warningMessage = "Using directives detected in multiple files" - val output = os.proc(TestUtil.cli, "compile", extraOptions, ".") - .call(cwd = root, stderr = os.Pipe).err.trim() - expect(!output.contains(warningMessage)) + test( + "java files with no using directives should not produce warnings about using directives in multiple files" + ) { + inputs.fromRoot { root => + val warningMessage = "Using directives detected in multiple files" + val output = os.proc(TestUtil.cli, "compile", extraOptions, ".") + .call(cwd = root, stderr = os.Pipe).err.trim() + expect(!output.contains(warningMessage)) + } + } + test("Pure Java with --server=false: no warning about .java files not being compiled") { + inputs.fromRoot { root => + val warningMessage = ".java files are not compiled to .class files" + val output = os.proc(TestUtil.cli, "compile", "--server=false", extraOptions, ".") + .call(cwd = root, stderr = os.Pipe).err.text() + expect(!output.contains(warningMessage)) + } } } @@ -140,7 +149,7 @@ abstract class CompileTestDefinitions } test( - "having target + using directives in files should not produce warnings about using directives in multiple files" + "having target + using directives in files: no using-directives or .java-not-compiled warnings" ) { val inputs = TestInputs( os.rel / "Bar.java" -> @@ -160,14 +169,14 @@ abstract class CompileTestDefinitions val output = os.proc(TestUtil.cli, "--power", "compile", extraOptions, ".") .call(cwd = root).err.trim() expect(!output.contains(warningMessage)) + expect(!output.contains(".java files are not compiled to .class files")) } } - test( - "warn about directives in multiple files" - ) { - val inputs = TestInputs( - os.rel / "Bar.java" -> + { + val javaSourceFile = "Bar.java" + val inputs = TestInputs( + os.rel / javaSourceFile -> """//> using jvm 17 |public class Bar {} |""".stripMargin, @@ -176,12 +185,24 @@ abstract class CompileTestDefinitions |class Foo {} |""".stripMargin ) + test("warn about directives in multiple files") { + inputs.fromRoot { root => + val warningMessage = "Using directives detected in multiple files" + val output = os.proc(TestUtil.cli, "--power", "compile", extraOptions, ".") + .call(cwd = root, stderr = os.Pipe).err.trim() + expect(output.contains(warningMessage)) + } + } - inputs.fromRoot { root => - val warningMessage = "Using directives detected in multiple files" - val output = os.proc(TestUtil.cli, "--power", "compile", extraOptions, ".") - .call(cwd = root, stderr = os.Pipe).err.trim() - expect(output.contains(warningMessage)) + test("mixed .java/.scala: with --server=false warn about .java not compiled") { + inputs.fromRoot { root => + val warningMessage = ".java files are not compiled to .class files" + val output = + os.proc(TestUtil.cli, "--power", "compile", extraOptions, ".", "--server=false") + .call(cwd = root, stderr = os.Pipe).err.trim() + expect(output.contains(warningMessage)) + expect(output.contains(javaSourceFile)) + } } } @@ -699,7 +720,9 @@ abstract class CompileTestDefinitions } } - test("pass java options to scalac when server=false") { + test( + "pass java options to scalac when server=false (Scala-only, no .java-not-compiled warning)" + ) { val inputs = TestInputs( os.rel / "Main.scala" -> """object Main extends App { @@ -721,6 +744,7 @@ abstract class CompileTestDefinitions val out = res.out.text() expect(out.contains("Error occurred during initialization of VM")) expect(out.contains("Too small maximum heap")) + expect(!out.contains(".java files are not compiled to .class files")) } } From 2352850e9169b843592de66eb0bba5f92b15dfea Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Wed, 18 Mar 2026 08:40:14 +0100 Subject: [PATCH 17/64] Add AGENTS.md & `agentskills` for directives & integration tests (#4178) --- AGENTS.md | 165 +++++++++++++++++++++++++ agentskills/README.md | 5 + agentskills/adding-directives/SKILL.md | 21 ++++ agentskills/integration-tests/SKILL.md | 19 +++ 4 files changed, 210 insertions(+) create mode 100644 AGENTS.md create mode 100644 agentskills/README.md create mode 100644 agentskills/adding-directives/SKILL.md create mode 100644 agentskills/integration-tests/SKILL.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..84d41dd48d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,165 @@ +# AGENTS.md — Guidance for AI agents contributing to Scala CLI + +Short reference for AI agents. For task-specific guidance (directives, integration tests), load skills from * +*[agentskills/](agentskills/)** when relevant. + +> **LLM Policy**: All AI-assisted contributions must comply with the +> [LLM usage policy](https://github.com/scala/scala3/blob/HEAD/LLM_POLICY.md). The contributor (human) is responsible +> for every line. State LLM usage in the PR description. See [LLM_POLICY.md](LLM_POLICY.md). + +## Human-facing docs + +- **[DEV.md](DEV.md)** — Setup, run from source, tests, launchers, GraalVM. +- **[CONTRIBUTING.md](CONTRIBUTING.md)** — PR workflow, formatting, reference doc generation. +- **[INTERNALS.md](INTERNALS.md)** — Modules, `Inputs → Sources → Build`, preprocessing. + +## Build system + +The project uses [Mill](https://mill-build.org/). Mill launchers ship with the repo (`./mill`). JVM 17 required. +Cross-compilation: default `Scala.defaultInternal`; `[]` = default version, `[_]` = all. + +### Key build files + +| File | Purpose | +|---------------------------------|------------------------------------------------------------------------------------------| +| `build.mill` | Root build definition: all module declarations, CI helper tasks, integration test wiring | +| `project/deps/package.mill` | Dependency versions and definitions (`Deps`, `Scala`, `Java` objects) | +| `project/settings/package.mill` | Shared traits, utils (`HasTests`, `CliLaunchers`, `FormatNativeImageConf`, etc.) | +| `project/publish/package.mill` | Publishing settings | +| `project/website/package.mill` | Website-related build tasks | + +### Essential commands + +```bash +./mill -i clean # Clean Mill context +./mill -i scala …args… # Run Scala CLI from source +./mill -i __.compile # Compile everything +./mill -i unitTests # All unit tests +./mill -i 'build-module[].test' # Unit tests for a specific module +./mill -i 'build-module[].test' 'scala.build.tests.BuildTestsScalac.*' # Filter by suite +./mill -i 'build-module[].test' 'scala.build.tests.BuildTests.simple' # Single test by name +./mill -i integration.test.jvm # Integration tests (JVM launcher) +./mill -i integration.test.jvm 'scala.cli.integration.RunTestsDefault.*' # Integration: filter by suite +./mill -i 'generate-reference-doc[]'.run # Regenerate reference docs +./mill -i __.fix # Fix import ordering (scalafix) +scala-cli fmt . # Format all code (scalafmt) +``` + +## Project modules + +Modules live under `modules/`. The dependency graph flows roughly as: + +``` +specification-level → config → core → options → directives → build-module → cli +``` + +### Module overview + +The list below may not be exhaustive — check `modules/` and `build.mill` for the current set. + +| Module | Purpose | +|-----------------------------------------------|------------------------------------------------------------------------------------------------------------------| +| `specification-level` | Defines `SpecificationLevel` (MUST / SHOULD / IMPLEMENTATION / RESTRICTED / EXPERIMENTAL) for SIP-46 compliance. | +| `config` | Scala CLI configuration keys and persistence. | +| `build-macros` | Compile-time macros (e.g. `EitherCps`). | +| `core` | Core types: `Inputs`, `Sources`, build constants, Bloop integration, JVM/JS/Native tooling. | +| `options` | `BuildOptions`, `SharedOptions`, and all option types. | +| `directives` | Using directive handlers — the bridge between `//> using` directives and `BuildOptions`. | +| `build-module` (aliased from `build` in mill) | The main build pipeline: preprocessing, compilation, post-processing. Most business logic lives here. | +| `cli` | Command definitions, argument parsing (CaseApp), the `ScalaCli` entry point. Packaged as the native image. | +| `runner` | Lightweight app that runs a main class and pretty-prints exceptions. Fetched at runtime. | +| `test-runner` | Discovers and runs test frameworks/suites. Fetched at runtime. | +| `tasty-lib` | Edits file names in `.tasty` files for source mapping. | +| `scala-cli-bsp` | BSP protocol types. | +| `integration` | Integration tests (see dedicated section below). | +| `docs-tests` | Tests that validate documentation (`Sclicheck`). | +| `generate-reference-doc` | Generates reference documentation from CLI option/directive metadata. | + +## Specification levels + +Every command, CLI option, and using directive has a `SpecificationLevel`. This is central to how features are exposed. + +| Level | In the Scala Runner spec? | Available without `--power`? | Stability | +|------------------|---------------------------|------------------------------|---------------------------------| +| `MUST` | Yes | Yes | Stable | +| `SHOULD` | Yes | Yes | Stable | +| `IMPLEMENTATION` | No | Yes | Stable | +| `RESTRICTED` | No | No (requires `--power`) | Stable | +| `EXPERIMENTAL` | No | No (requires `--power`) | Unstable — may change/disappear | + +**New features contributed by agents should generally be marked `EXPERIMENTAL`** unless the maintainers explicitly +request otherwise. This applies to new sub-commands, options, and directives alike. + +The specification level is set via: + +- **Directives**: `@DirectiveLevel(SpecificationLevel.EXPERIMENTAL)` annotation on the directive case class. +- **CLI options**: `@Tag(tags.experimental)` annotation on option fields. +- **Commands**: Override `scalaSpecificationLevel` in the command class. + +## Using directives + +Using directives are in-source configuration comments: + +```scala +//> using scala 3 +//> using dep com.lihaoyi::os-lib:0.11.4 +//> using test.dep org.scalameta::munit::1.1.1 +``` + +Directives are parsed by `using_directives`, then `ExtractedDirectives` → `DirectivesPreprocessor` → `BuildOptions`/ +`BuildRequirements`. **CLI options override directive values.** To add a new directive, +see [agentskills/adding-directives/](agentskills/adding-directives/SKILL.md). + +## Testing + +> **Every contribution that changes logic must include automated tests.** A PR without tests for +> new or changed behavior will not be accepted. If testing is truly infeasible, explain why in the +> PR description — but this should be exceptional. + +> **Unit tests are always preferred over integration tests.** Unit tests are faster, more reliable, +> easier to debug, and cheaper to run on CI. Only add integration tests when the behavior cannot be +> adequately verified at the unit level (e.g. end-to-end CLI invocation, launcher-specific behavior, +> cross-process interactions). + +> **Always re-run and verify tests locally before submitting.** After any logic change, run the +> relevant test suites on your machine and confirm they pass. Do not rely on CI to catch failures — +> CI resources are shared, and broken PRs waste maintainer time. + +**Unit tests**: munit, in each module’s `test` submodule. Run commands above; add tests in `modules/build/.../tests/` or +`modules/cli/src/test/scala/`. Prefer unit over integration. + +**Integration tests**: `modules/integration/`; they run the CLI as a subprocess. +See [agentskills/integration-tests/](agentskills/integration-tests/SKILL.md) for structure and how to add tests. + +## Pre-PR checklist + +1. Code compiles: `./mill -i __.compile` +2. Tests added and passing locally (unit tests first, integration if needed) +3. Code formatted: `scala-cli fmt .` +4. Imports ordered: `./mill -i __.fix` +5. Reference docs regenerated (if options/directives changed): `./mill -i 'generate-reference-doc[]'.run` +6. PR template filled, LLM usage stated + +## Code style + +Code style is enforced. + +**Scala 3**: Prefer `if … then … else`, `for … do`/`yield`, `enum`, `extension`, `given`/`using`, braceless blocks, +top-level defs. Use union/intersection types when they simplify signatures. Always favor Scala 3 idiomatic syntax. + +**Functional**: Prefer `val`, immutable collections, `case class`.copy(). Prefer expressions over statements; prefer +`map`/`flatMap`/`fold`/`for`-comprehensions over loops. Use `@tailrec` for tail recursion. Avoid `null`; use `Option`/ +`Either`/`EitherCps` (build-macros). Keep functions small; extract helpers. + +**No duplication**: Extract repeated logic into shared traits or utils (`*Options` traits, companion helpers, +`CommandHelpers`, `TestUtil`). Check for existing abstractions before copying. + +**Logging**: Use the project `Logger` only — never `System.err` or `System.out`. Logger respects verbosity (`-v`, `-q`). +Use `logger.message(msg)` (default), `logger.log(msg)` (verbose), `logger.debug(msg)` (debug), `logger.error(msg)` ( +always). In commands: `options.shared.logging.logger`; in build code it is passed in; in tests use `TestLogger`. + +**Mutability**: OK in hot paths or when a Java API requires it; keep scope minimal. + +## Further reference + +[DEV.md](DEV.md), [CONTRIBUTING.md](CONTRIBUTING.md), [INTERNALS.md](INTERNALS.md). diff --git a/agentskills/README.md b/agentskills/README.md new file mode 100644 index 0000000000..e79a6b9e40 --- /dev/null +++ b/agentskills/README.md @@ -0,0 +1,5 @@ +# Agent skills (Scala CLI) + +This directory holds **agent skills** — task-specific guidance loaded on demand by AI coding agents. The layout is tool-agnostic; Cursor, Claude Code, Codex, and other tools that support a standard skill directory can use this (e.g. by configuring or symlinking to `.agents/skills/` if required). + +Each subdirectory contains a `SKILL.md` with frontmatter and instructions. See [agentskills/agentskills](https://github.com/agentskills/agentskills) for the open standard. diff --git a/agentskills/adding-directives/SKILL.md b/agentskills/adding-directives/SKILL.md new file mode 100644 index 0000000000..54776d1134 --- /dev/null +++ b/agentskills/adding-directives/SKILL.md @@ -0,0 +1,21 @@ +--- +name: scala-cli-adding-directives +description: Add or change using directives in Scala CLI. Use when adding a new //> using directive, registering a directive handler, or editing directive preprocessing. +--- + +# Adding a new directive (Scala CLI) + +1. **Create a case class** in `modules/directives/src/main/scala/scala/build/preprocessing/directives/` extending one of: + - `HasBuildOptions` — produces `BuildOptions` directly + - `HasBuildOptionsWithRequirements` — produces `BuildOptions` with scoped requirements (e.g. `test.dep`) + - `HasBuildRequirements` — produces `BuildRequirements` (for `//> require`) + +2. **Annotate**: `@DirectiveLevel(SpecificationLevel.EXPERIMENTAL)`, `@DirectiveDescription("…")`, `@DirectiveUsage("…")`, `@DirectiveExamples("…")`, `@DirectiveName("key")` on fields. + +3. **Companion**: `val handler: DirectiveHandler[YourDirective] = DirectiveHandler.derive` + +4. **Register** in `modules/build/.../DirectivesPreprocessingUtils.scala` in the right list: `usingDirectiveHandlers`, `usingDirectiveWithReqsHandlers`, or `requireDirectiveHandlers`. + +5. **Regenerate reference docs**: `./mill -i 'generate-reference-doc[]'.run` + +CLI options always override directive values when both set the same thing. diff --git a/agentskills/integration-tests/SKILL.md b/agentskills/integration-tests/SKILL.md new file mode 100644 index 0000000000..ec77e99465 --- /dev/null +++ b/agentskills/integration-tests/SKILL.md @@ -0,0 +1,19 @@ +--- +name: scala-cli-integration-tests +description: Add or run Scala CLI integration tests. Use when adding integration tests, debugging RunTests/CompileTests/etc., or working in modules/integration. +--- + +# Integration tests (Scala CLI) + +**Location**: `modules/integration/`. Tests invoke the CLI as an external process. + +**Run**: `./mill -i integration.test.jvm` (all). Filter: `./mill -i integration.test.jvm 'scala.cli.integration.RunTestsDefault.*'` or by test name. Native: `./mill -i integration.test.native`. + +**Structure**: `*TestDefinitions.scala` (abstract, holds test logic) → `*TestsDefault`, `*Tests213`, etc. (concrete, Scala version trait). Traits: `TestDefault`, `Test212`, `Test213`, `Test3Lts`, `Test3NextRc`. + +**Adding a test**: +1. Open the right `*TestDefinitions` (e.g. `RunTestDefinitions` for `run`). +2. Add `test("description") { … }` using `TestInputs(os.rel / "Main.scala" -> "…").fromRoot { root => … }` and `os.proc(TestUtil.cli, "run", …).call(cwd = root)`. +3. Assert on stdout/stderr. + +**Helpers**: `TestInputs(...).fromRoot`, `TestUtil.cli`. Test groups (CI): `SCALA_CLI_IT_GROUP=1..5`; see `modules/integration/` for group mapping. From 5ed4f795adcd4e5a55797b9102bd633ee83dccb1 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Wed, 18 Mar 2026 08:49:02 +0100 Subject: [PATCH 18/64] Add support for local `.m2` in `publish local` (#4179) --- .../scala/cli/commands/publish/Publish.scala | 17 ++- .../cli/commands/publish/PublishLocal.scala | 11 ++ .../publish/PublishLocalOptions.scala | 16 ++- .../cli/commands/publish/RepoParams.scala | 17 +++ .../PublishLocalTestDefinitions.scala | 110 ++++++++++++++++++ website/docs/reference/cli-options.md | 18 +++ website/docs/reference/commands.md | 4 +- 7 files changed, 189 insertions(+), 4 deletions(-) diff --git a/modules/cli/src/main/scala/scala/cli/commands/publish/Publish.scala b/modules/cli/src/main/scala/scala/cli/commands/publish/Publish.scala index efe1f1e7ab..2f253e011d 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/publish/Publish.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/publish/Publish.scala @@ -256,6 +256,8 @@ object Publish extends ScalaCommand[PublishOptions] with BuildCommandHelpers { workingDir, ivy2HomeOpt, publishLocal = false, + m2Local = false, + m2HomeOpt = None, forceSigningExternally = options.signingCli.forceSigningExternally.getOrElse(false), parallelUpload = options.parallelUpload, options.watch.watch, @@ -279,6 +281,8 @@ object Publish extends ScalaCommand[PublishOptions] with BuildCommandHelpers { workingDir: => os.Path, ivy2HomeOpt: Option[os.Path], publishLocal: Boolean, + m2Local: Boolean = false, + m2HomeOpt: Option[os.Path] = None, forceSigningExternally: Boolean, parallelUpload: Option[Boolean], watch: Boolean, @@ -309,6 +313,8 @@ object Publish extends ScalaCommand[PublishOptions] with BuildCommandHelpers { workingDir = workingDir, ivy2HomeOpt = ivy2HomeOpt, publishLocal = publishLocal, + m2Local = m2Local, + m2HomeOpt = m2HomeOpt, logger = logger, allowExit = false, forceSigningExternally = forceSigningExternally, @@ -342,6 +348,8 @@ object Publish extends ScalaCommand[PublishOptions] with BuildCommandHelpers { workingDir = workingDir, ivy2HomeOpt = ivy2HomeOpt, publishLocal = publishLocal, + m2Local = m2Local, + m2HomeOpt = m2HomeOpt, logger = logger, allowExit = true, forceSigningExternally = forceSigningExternally, @@ -363,6 +371,8 @@ object Publish extends ScalaCommand[PublishOptions] with BuildCommandHelpers { workingDir: os.Path, ivy2HomeOpt: Option[os.Path], publishLocal: Boolean, + m2Local: Boolean, + m2HomeOpt: Option[os.Path], logger: Logger, allowExit: Boolean, forceSigningExternally: Boolean, @@ -419,6 +429,8 @@ object Publish extends ScalaCommand[PublishOptions] with BuildCommandHelpers { workingDir = workingDir, ivy2HomeOpt = ivy2HomeOpt, publishLocal = publishLocal, + m2Local = m2Local, + m2HomeOpt = m2HomeOpt, logger = logger, forceSigningExternally = forceSigningExternally, parallelUpload = parallelUpload, @@ -687,6 +699,8 @@ object Publish extends ScalaCommand[PublishOptions] with BuildCommandHelpers { workingDir: os.Path, ivy2HomeOpt: Option[os.Path], publishLocal: Boolean, + m2Local: Boolean, + m2HomeOpt: Option[os.Path], logger: Logger, forceSigningExternally: Boolean, parallelUpload: Option[Boolean], @@ -741,7 +755,8 @@ object Publish extends ScalaCommand[PublishOptions] with BuildCommandHelpers { lazy val es = Executors.newSingleThreadScheduledExecutor(Util.daemonThreadFactory("publish-retry")) - if publishLocal then RepoParams.ivy2Local(ivy2HomeOpt) + if publishLocal && m2Local then RepoParams.m2Local(m2HomeOpt) + else if publishLocal then RepoParams.ivy2Local(ivy2HomeOpt) else value { publishOptions.contextual(isCi).repository match { diff --git a/modules/cli/src/main/scala/scala/cli/commands/publish/PublishLocal.scala b/modules/cli/src/main/scala/scala/cli/commands/publish/PublishLocal.scala index d81158c7ff..c91ac6c241 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/publish/PublishLocal.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/publish/PublishLocal.scala @@ -35,6 +35,11 @@ object PublishLocal extends ScalaCommand[PublishLocalOptions] { Publish.maybePrintLicensesAndExit(options.publishParams) Publish.maybePrintChecksumsAndExit(options.sharedPublish) + if options.m2 && options.sharedPublish.ivy2Home.exists(_.trim.nonEmpty) then { + logger.error("--m2 and --ivy2-home are mutually exclusive.") + sys.exit(1) + } + val baseOptions = buildOptionsOrExit(options) val inputs = options.shared.inputs(args.all).orExit(logger) CurrentParams.workspaceOpt = Some(inputs.workspace) @@ -71,6 +76,10 @@ object PublishLocal extends ScalaCommand[PublishLocalOptions] { .filter(_.trim.nonEmpty) .map(os.Path(_, os.pwd)) + val m2HomeOpt = options.m2Home + .filter(_.trim.nonEmpty) + .map(os.Path(_, os.pwd)) + Publish.doRun( inputs = inputs, logger = logger, @@ -81,6 +90,8 @@ object PublishLocal extends ScalaCommand[PublishLocalOptions] { workingDir = workingDir, ivy2HomeOpt = ivy2HomeOpt, publishLocal = true, + m2Local = options.m2, + m2HomeOpt = m2HomeOpt, forceSigningExternally = options.scalaSigning.forceSigningExternally.getOrElse(false), parallelUpload = Some(true), watch = options.watch.watch, diff --git a/modules/cli/src/main/scala/scala/cli/commands/publish/PublishLocalOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/publish/PublishLocalOptions.scala index e41bca8793..dc4409ee0d 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/publish/PublishLocalOptions.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/publish/PublishLocalOptions.scala @@ -4,6 +4,7 @@ import caseapp.* import scala.cli.commands.pgp.PgpScalaSigningOptions import scala.cli.commands.shared.* +import scala.cli.commands.tags // format: off @HelpMessage(PublishLocalOptions.helpMessage, "", PublishLocalOptions.detailedHelpMessage) @@ -22,6 +23,19 @@ final case class PublishLocalOptions( sharedPublish: SharedPublishOptions = SharedPublishOptions(), @Recurse scalaSigning: PgpScalaSigningOptions = PgpScalaSigningOptions(), + + @Group(HelpGroup.Publishing.toString) + @HelpMessage("Publish to the local Maven repository (defaults to ~/.m2/repository) instead of Ivy2 local") + @Name("mavenLocal") + @Tag(tags.experimental) + @Tag(tags.inShortHelp) + m2: Boolean = false, + + @Group(HelpGroup.Publishing.toString) + @HelpMessage("Set the local Maven repository path (defaults to ~/.m2/repository)") + @ValueDescription("path") + @Tag(tags.experimental) + m2Home: Option[String] = None, ) extends HasSharedOptions with HasSharedWatchOptions // format: on @@ -29,7 +43,7 @@ object PublishLocalOptions { implicit lazy val parser: Parser[PublishLocalOptions] = Parser.derive implicit lazy val help: Help[PublishLocalOptions] = Help.derive val cmdName = "publish local" - private val helpHeader = "Publishes build artifacts to the local Ivy2 repository." + private val helpHeader = "Publishes build artifacts to the local Ivy2 or Maven repository." private val docWebsiteSuffix = "publishing/publish-local" val helpMessage: String = s"""$helpHeader diff --git a/modules/cli/src/main/scala/scala/cli/commands/publish/RepoParams.scala b/modules/cli/src/main/scala/scala/cli/commands/publish/RepoParams.scala index 3056716e2e..e49aa9d8c7 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/publish/RepoParams.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/publish/RepoParams.scala @@ -80,6 +80,8 @@ object RepoParams { repo match { case "ivy2-local" => RepoParams.ivy2Local(ivy2HomeOpt) + case "m2-local" | "maven-local" => + RepoParams.m2Local(None) case "sonatype" | "central" | "maven-central" | "mvn-central" => logger.message(s"Using Portal OSSRH Staging API: $sonatypeOssrhStagingApiBase") RepoParams.centralRepo( @@ -245,4 +247,19 @@ object RepoParams { ) } + def m2Local(m2HomeOpt: Option[os.Path]): RepoParams = { + val base = m2HomeOpt.getOrElse(os.home / ".m2" / "repository") + RepoParams( + repo = PublishRepository.Simple(MavenRepository(base.toNIO.toUri.toASCIIString)), + targetRepoOpt = None, + hooks = Hooks.dummy, + isIvy2LocalLike = false, + defaultParallelUpload = true, + supportsSig = true, + acceptsChecksums = true, + shouldSign = false, + shouldAuthenticate = false + ) + } + } diff --git a/modules/integration/src/test/scala/scala/cli/integration/PublishLocalTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/PublishLocalTestDefinitions.scala index d26469eeb2..94fbc8f9af 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/PublishLocalTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/PublishLocalTestDefinitions.scala @@ -349,6 +349,116 @@ abstract class PublishLocalTestDefinitions extends ScalaCliSuite with TestScalaV } } + test("publish local --m2") { + val expectedFiles = { + val modName = s"${PublishTestInputs.testName}_$testedPublishedScalaVersion" + val base = + os.rel / PublishTestInputs.testOrg.split('.').toSeq / modName / testPublishVersion + val baseFiles = Seq( + base / s"$modName-$testPublishVersion.jar", + base / s"$modName-$testPublishVersion.pom", + base / s"$modName-$testPublishVersion-sources.jar", + base / s"$modName-$testPublishVersion-javadoc.jar" + ) + baseFiles + .flatMap { f => + val md5 = f / os.up / s"${f.last}.md5" + val sha1 = f / os.up / s"${f.last}.sha1" + Seq(f, md5, sha1) + } + .toSet + } + + PublishTestInputs.inputs() + .fromRoot { root => + os.proc( + TestUtil.cli, + "--power", + "publish", + "local", + ".", + "--m2", + "--m2-home", + (root / "m2repo").toString, + extraOptions + ) + .call(cwd = root) + val m2Local = root / "m2repo" + val foundFiles = os.walk(m2Local) + .filter(os.isFile(_)) + .map(_.relativeTo(m2Local)) + .toSet + val missingFiles = expectedFiles -- foundFiles + val unexpectedFiles = foundFiles -- expectedFiles + if (missingFiles.nonEmpty) + pprint.err.log(missingFiles) + if (unexpectedFiles.nonEmpty) + pprint.err.log(unexpectedFiles) + expect(missingFiles.isEmpty) + expect(unexpectedFiles.isEmpty) + } + } + + test("publish local --m2 twice") { + PublishTestInputs.inputs().fromRoot { root => + val m2Repo = root / "m2repo" + val modName = s"${PublishTestInputs.testName}_$testedPublishedScalaVersion" + val jarPath = m2Repo / + PublishTestInputs.testOrg.split('.').toSeq / + modName / testPublishVersion / s"$modName-$testPublishVersion.jar" + + def publishLocal(): os.CommandResult = + os.proc( + TestUtil.cli, + "--power", + "publish", + "local", + ".", + "--m2", + "--m2-home", + m2Repo.toString, + "--working-dir", + os.rel / "work-dir", + extraOptions + ) + .call(cwd = root) + + lazy val depsCp: String = + os.proc( + TestUtil.cs, + "fetch", + "--classpath", + s"com.lihaoyi:os-lib_$testedPublishedScalaVersion:0.11.3" + ) + .call(cwd = root) + .out.trim() + + def output(): String = + os.proc( + "java", + "-cp", + s"$jarPath${java.io.File.pathSeparator}$depsCp", + "Project" + ) + .call(cwd = root) + .out.trim() + + val expectedMessage1 = "Hello" + val expectedMessage2 = "olleH" + publishLocal() + val output1 = output() + expect(output1 == expectedMessage1) + + os.write.over( + root / PublishTestInputs.projectFilePath, + PublishTestInputs.projFile(expectedMessage2) + ) + publishLocal() + val output2 = output() + expect(output2 == expectedMessage2) + } + } + if actualScalaVersion.startsWith("3") then test("publish local with compileOnly.dep") { TestInputs( diff --git a/website/docs/reference/cli-options.md b/website/docs/reference/cli-options.md index 5a764875ef..6d0ec34942 100644 --- a/website/docs/reference/cli-options.md +++ b/website/docs/reference/cli-options.md @@ -1088,6 +1088,24 @@ Proceed as if publishing, but do not upload / write artifacts to the remote repo ### `--parallel-upload` [Internal] +## Publish local options + +Available in commands: + +[`publish local`](./commands.md#publish-local) + + + +### `--m2` + +Aliases: `--maven-local` + +Publish to the local Maven repository (defaults to ~/.m2/repository) instead of Ivy2 local + +### `--m2-home` + +Set the local Maven repository path (defaults to ~/.m2/repository) + ## Publish params options Available in commands: diff --git a/website/docs/reference/commands.md b/website/docs/reference/commands.md index 3b375f4054..64ef81f38d 100644 --- a/website/docs/reference/commands.md +++ b/website/docs/reference/commands.md @@ -261,7 +261,7 @@ Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [c ## publish local -Publishes build artifacts to the local Ivy2 repository. +Publishes build artifacts to the local Ivy2 or Maven repository. For detailed documentation refer to our website: https://scala-cli.virtuslab.org/docs/commands/publishing/publish-local @@ -269,7 +269,7 @@ The `publish-local` sub-command is experimental. Please bear in mind that non-ideal user experience should be expected. If you encounter any bugs or have feedback to share, make sure to reach out to the maintenance team at https://github.com/VirtusLab/scala-cli -Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [cross](./cli-options.md#cross-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [main class](./cli-options.md#main-class-options), [markdown](./cli-options.md#markdown-options), [pgp scala signing](./cli-options.md#pgp-scala-signing-options), [power](./cli-options.md#power-options), [publish](./cli-options.md#publish-options), [publish params](./cli-options.md#publish-params-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [watch](./cli-options.md#watch-options), [workspace](./cli-options.md#workspace-options) +Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [cross](./cli-options.md#cross-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [main class](./cli-options.md#main-class-options), [markdown](./cli-options.md#markdown-options), [pgp scala signing](./cli-options.md#pgp-scala-signing-options), [power](./cli-options.md#power-options), [publish](./cli-options.md#publish-options), [publish local](./cli-options.md#publish-local-options), [publish params](./cli-options.md#publish-params-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [watch](./cli-options.md#watch-options), [workspace](./cli-options.md#workspace-options) ## publish setup From 1491ac37f9d1f254e072a107b53da2bbb11b066b Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Thu, 19 Mar 2026 00:52:49 +0100 Subject: [PATCH 19/64] Add release notes for Scala CLI v1.12.5 (#4190) --- website/docs/release_notes.md | 92 +++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/website/docs/release_notes.md b/website/docs/release_notes.md index aafe1d29da..1406e1074a 100644 --- a/website/docs/release_notes.md +++ b/website/docs/release_notes.md @@ -8,6 +8,98 @@ import ReactPlayer from 'react-player' # Release notes +## [v1.12.5](https://github.com/VirtusLab/scala-cli/releases/tag/v1.12.5) + +### `--cross` support for `run`, `package` and `doc` sub-commands (experimental ⚡️) +It is now possible to cross-`run`, cross-`package` and cross-generate docs (`doc`) with the `--cross` command line +option. +- `run` runs each configured combination of Scala version and platform (e.g. JVM, Native, JS) in sequence; +- `package` produces one artifact per cross build, with the Scala version and platform in the artifact name; +- `doc` generates Scaladoc for each cross target into separate output directories. + +```scala title=cross.scala +//> using scala 3.3 3.8 +@main def main() = println("Hello") +``` + +```bash +scala-cli run cross.scala --cross --power +scala-cli package cross.scala --cross --power +scala-cli doc cross.scala --cross -o doc-out --power +``` + +Added by [@Gedochao](https://github.com/Gedochao) in [#3808](https://github.com/VirtusLab/scala-cli/pull/3808), [#4171](https://github.com/VirtusLab/scala-cli/pull/4171) & [#4183](https://github.com/VirtusLab/scala-cli/pull/4183) + +### Global `--offline` config key +You can set offline mode globally with the `config` sub-command, so Scala CLI uses the cache and skips network access +without passing `--offline` every time. + +```bash ignore +scala-cli config offline true +``` + +Added by [@Gedochao](https://github.com/Gedochao) in [#3216](https://github.com/VirtusLab/scala-cli/pull/3216) + +### Watch extra paths with `--watching` (experimental ⚡️) +Use the `--watching` option or `//> using watching` to have `--watch` re-run when files or directories outside +your sources change (e.g. config or assets). + +```bash ignore +scala-cli run . --watch --power --watching ./config --watching ./assets +``` + +Or in source: + +```scala compile power +//> using watching ./config ./assets +``` + +Added by [@Gedochao](https://github.com/Gedochao) in [#4174](https://github.com/VirtusLab/scala-cli/pull/4174) + +### Local `.m2` in `publish local` (experimental ⚡️) +`publish local` now publishes to your local Maven repository (`~/.m2`), so other local projects can depend +on the published artifacts via Maven coordinates. + +```bash ignore +scala-cli publish local . --m2 --power +``` + +Added by [@Gedochao](https://github.com/Gedochao) in [#4179](https://github.com/VirtusLab/scala-cli/pull/4179) + +### Features +* Run all cross builds when `--cross` is passed by [@Gedochao](https://github.com/Gedochao) in [#3808](https://github.com/VirtusLab/scala-cli/pull/3808) +* Add a global `--offline` config key by [@Gedochao](https://github.com/Gedochao) in [#3216](https://github.com/VirtusLab/scala-cli/pull/3216) +* Support `--cross` with the `package` sub-command by [@Gedochao](https://github.com/Gedochao) in [#4171](https://github.com/VirtusLab/scala-cli/pull/4171) +* Allow to `--watch` extra paths with `--watching` by [@Gedochao](https://github.com/Gedochao) in [#4174](https://github.com/VirtusLab/scala-cli/pull/4174) +* Add support for `--cross` in the `doc` sub-command by [@Gedochao](https://github.com/Gedochao) in [#4183](https://github.com/VirtusLab/scala-cli/pull/4183) +* Add support for local `.m2` in `publish local` by [@Gedochao](https://github.com/Gedochao) in [#4179](https://github.com/VirtusLab/scala-cli/pull/4179) + +### Fixes +* Use Java 17 mapping when generating docs with Scala 3.8+ with `doc` by [@Gedochao](https://github.com/Gedochao) in [#4180](https://github.com/VirtusLab/scala-cli/pull/4180) +* Make test framework discovery on Native more resilient & with better errors by [@Gedochao](https://github.com/Gedochao) in [#4185](https://github.com/VirtusLab/scala-cli/pull/4185) +* Warn when `.java` & `.scala` sources are used in a mixed compilation with `--server=false` by [@Gedochao](https://github.com/Gedochao) in [#4181](https://github.com/VirtusLab/scala-cli/pull/4181) + +### Build and internal changes +* Add LLM policy & a PR template by [@Gedochao](https://github.com/Gedochao) in [#4177](https://github.com/VirtusLab/scala-cli/pull/4177) +* Add `AGENTS.md` by [@Gedochao](https://github.com/Gedochao) in [#4178](https://github.com/VirtusLab/scala-cli/pull/4178) + +### Updates +* Bump the npm-dependencies group in /website with 3 updates by @dependabot[bot] in [#4165](https://github.com/VirtusLab/scala-cli/pull/4165) +* Bump the github-actions group with 3 updates by @dependabot[bot] in [#4164](https://github.com/VirtusLab/scala-cli/pull/4164) +* Update scala-cli.sh launcher for 1.12.4 by @github-actions[bot] in [#4166](https://github.com/VirtusLab/scala-cli/pull/4166) +* Bump svgo from 3.3.2 to 3.3.3 in /website by @dependabot[bot] in [#4168](https://github.com/VirtusLab/scala-cli/pull/4168) +* Bump immutable from 5.1.4 to 5.1.5 in /website by @dependabot[bot] in [#4167](https://github.com/VirtusLab/scala-cli/pull/4167) +* Bump Mill to 1.1.3 (was 1.1.2) by [@Gedochao](https://github.com/Gedochao) in [#4169](https://github.com/VirtusLab/scala-cli/pull/4169) +* Bump @algolia/client-search from 5.49.1 to 5.49.2 in /website in the npm-dependencies group by @dependabot[bot] in [#4173](https://github.com/VirtusLab/scala-cli/pull/4173) +* Bump the github-actions group with 4 updates by @dependabot[bot] in [#4172](https://github.com/VirtusLab/scala-cli/pull/4172) +* Update Scala 3 Next RC to 3.8.3-RC2 by [@Gedochao](https://github.com/Gedochao) in [#4175](https://github.com/VirtusLab/scala-cli/pull/4175) +* Bump undici from 7.18.2 to 7.24.1 in /website by @dependabot[bot] in [#4182](https://github.com/VirtusLab/scala-cli/pull/4182) +* Bump webfactory/ssh-agent from 0.9.1 to 0.10.0 in the github-actions group by @dependabot[bot] in [#4187](https://github.com/VirtusLab/scala-cli/pull/4187) +* Bump `coursier` to 2.1.25-M24 by [@Gedochao](https://github.com/Gedochao) in [#4184](https://github.com/VirtusLab/scala-cli/pull/4184) +* Bump sass from 1.97.3 to 1.98.0 in /website in the npm-dependencies group by @dependabot[bot] in [#4188](https://github.com/VirtusLab/scala-cli/pull/4188) + +**Full Changelog**: https://github.com/VirtusLab/scala-cli/compare/v1.12.4...v1.12.5 + ## [v1.12.4](https://github.com/VirtusLab/scala-cli/releases/tag/v1.12.4) This is just a small patch fixing a bug ([#4152](https://github.com/VirtusLab/scala-cli/issues/4152)) breaking Metals support in Scala CLI v1.12.3. From 57cff2e2df4ef0ef677c4777464e54237abeb49e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 22 Mar 2026 16:38:28 +0000 Subject: [PATCH 20/64] Update scala-cli.sh launcher for 1.12.5 (#4191) Co-authored-by: gh-actions --- scala-cli.bat | 2 +- scala-cli.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scala-cli.bat b/scala-cli.bat index b5a0861320..046d5b2a77 100644 --- a/scala-cli.bat +++ b/scala-cli.bat @@ -7,7 +7,7 @@ rem Download the latest version of this script at https://github.com/VirtusLab/s setlocal enabledelayedexpansion -set "SCALA_CLI_VERSION=1.12.4" +set "SCALA_CLI_VERSION=1.12.5" set SCALA_CLI_URL=https://github.com/VirtusLab/scala-cli/releases/download/v%SCALA_CLI_VERSION%/scala-cli.bat set CACHE_BASE=%localappdata%/Coursier/v1 diff --git a/scala-cli.sh b/scala-cli.sh index bbde4f901e..d4b68849fa 100755 --- a/scala-cli.sh +++ b/scala-cli.sh @@ -7,7 +7,7 @@ set -eu -SCALA_CLI_VERSION="1.12.4" +SCALA_CLI_VERSION="1.12.5" GH_ORG="VirtusLab" GH_NAME="scala-cli" From 0b036e551c5d9cf487614045fb3d9224d6ee3abe Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Sun, 22 Mar 2026 18:12:31 +0100 Subject: [PATCH 21/64] Bump Scala 3 Next RC to 3.8.3-RC3 (#4194) --- project/deps/package.mill | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/project/deps/package.mill b/project/deps/package.mill index 3d21cca463..57f6494f0f 100644 --- a/project/deps/package.mill +++ b/project/deps/package.mill @@ -21,8 +21,8 @@ object Scala { def scala3NextPrefix = "3.8" def scala3Next = s"$scala3NextPrefix.2" // the newest/next version of Scala def scala3NextAnnounced = scala3Next // the newest/next version of Scala that's been announced - def scala3NextRc = "3.8.3-RC2" // the latest RC version of Scala Next - def scala3NextRcAnnounced = "3.8.3-RC1" // the latest announced RC version of Scala Next + def scala3NextRc = "3.8.3-RC3" // the latest RC version of Scala Next + def scala3NextRcAnnounced = "3.8.3-RC2" // the latest announced RC version of Scala Next // The Scala version used to build the CLI itself. def defaultInternal = sys.props.get("scala.version.internal").getOrElse(scala3Lts) From 91697423ec970373b724c131cd477ee299db4f0d Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Tue, 24 Mar 2026 11:34:07 +0100 Subject: [PATCH 22/64] Support formatting .sbt inputs (#4195) --- .../scala/scala/build/input/Element.scala | 5 +++ .../scala/build/input/ElementsUtils.scala | 3 ++ .../main/scala/scala/build/input/Inputs.scala | 2 ++ .../scala/scala/build/tests/InputsTests.scala | 36 +++++++++++++++---- .../scala/scala/cli/commands/fmt/Fmt.scala | 4 +-- .../scala/scala/cli/commands/run/Run.scala | 1 + .../integration/CompileTestDefinitions.scala | 13 +++++++ .../cli/integration/FixTestDefinitions.scala | 24 +++++++++++++ .../scala/cli/integration/FmtTests.scala | 31 ++++++++++++++++ .../integration/PackageTestDefinitions.scala | 22 ++++++++++++ .../cli/integration/RunTestDefinitions.scala | 15 ++++++++ 11 files changed, 147 insertions(+), 9 deletions(-) diff --git a/modules/build/src/main/scala/scala/build/input/Element.scala b/modules/build/src/main/scala/scala/build/input/Element.scala index 5f2c941706..f89ea71f52 100644 --- a/modules/build/src/main/scala/scala/build/input/Element.scala +++ b/modules/build/src/main/scala/scala/build/input/Element.scala @@ -107,6 +107,11 @@ final case class MarkdownFile(base: os.Path, subPath: os.SubPath) lazy val path: os.Path = base / subPath } +final case class SbtFile(base: os.Path, subPath: os.SubPath) + extends OnDisk with SourceFile { + lazy val path: os.Path = base / subPath +} + final case class Directory(path: os.Path) extends OnDisk with Compiled final case class ResourceDirectory(path: os.Path) extends OnDisk diff --git a/modules/build/src/main/scala/scala/build/input/ElementsUtils.scala b/modules/build/src/main/scala/scala/build/input/ElementsUtils.scala index 2c79db3284..4e49678376 100644 --- a/modules/build/src/main/scala/scala/build/input/ElementsUtils.scala +++ b/modules/build/src/main/scala/scala/build/input/ElementsUtils.scala @@ -34,6 +34,8 @@ object ElementsUtils { case p if p.last.endsWith(".sc") => // TODO: hasShebang test without consuming 1st 2 bytes of Stream Script(d.path, p.subRelativeTo(d.path), None) + case p if p.last.endsWith(".sbt") => + SbtFile(d.path, p.subRelativeTo(d.path)) } .toVector .sortBy(_.subPath.segments) @@ -68,6 +70,7 @@ object ElementsUtils { case _: Script => "sc:" case _: MarkdownFile => "md:" case _: JarFile => "jar:" + case _: SbtFile => "sbt:" } Iterator(prefix, elem.path.toString, "\n").map(bytes) case v: Virtual => diff --git a/modules/build/src/main/scala/scala/build/input/Inputs.scala b/modules/build/src/main/scala/scala/build/input/Inputs.scala index 055be9baa0..2e608b28db 100644 --- a/modules/build/src/main/scala/scala/build/input/Inputs.scala +++ b/modules/build/src/main/scala/scala/build/input/Inputs.scala @@ -104,6 +104,7 @@ final case class Inputs( Seq("dir:") ++ dirInput.singleFilesFromDirectory(enableMarkdown) .map(file => s"${file.path}:" + os.read(file.path)) case _: ResourceDirectory => Nil + case _: SbtFile => Nil case _ => Seq(os.read(elem.path)) } (Iterator(elem.path.toString) ++ content.iterator ++ Iterator("\n")).map(bytes) @@ -282,6 +283,7 @@ object Inputs { else if arg.endsWith(".java") then Right(Seq(JavaFile(dir, subPath))) else if arg.endsWith(".jar") then Right(Seq(JarFile(dir, subPath))) else if arg.endsWith(".c") || arg.endsWith(".h") then Right(Seq(CFile(dir, subPath))) + else if arg.endsWith(".sbt") then Right(Seq(SbtFile(dir, subPath))) else if arg.endsWith(".md") then Right(Seq(MarkdownFile(dir, subPath))) else if acceptFds && arg.startsWith("/dev/fd/") then Right(Seq(VirtualScript(content, arg, os.sub / s"input-${idx + 1}.sc"))) diff --git a/modules/build/src/test/scala/scala/build/tests/InputsTests.scala b/modules/build/src/test/scala/scala/build/tests/InputsTests.scala index 5cf10bdd3e..1707975241 100644 --- a/modules/build/src/test/scala/scala/build/tests/InputsTests.scala +++ b/modules/build/src/test/scala/scala/build/tests/InputsTests.scala @@ -3,14 +3,8 @@ package scala.build.tests import bloop.rifle.BloopRifleConfig import com.eed3si9n.expecty.Expecty.expect +import scala.build.input.* import scala.build.input.ElementsUtils.* -import scala.build.input.{ - Inputs, - ScalaCliInvokeData, - VirtualJavaFile, - VirtualScalaFile, - VirtualScript -} import scala.build.internal.Constants import scala.build.options.{BuildOptions, InternalOptions} import scala.build.tests.util.BloopServer @@ -127,6 +121,34 @@ class InputsTests extends TestUtil.ScalaCliBuildSuite { } } + test("sbt file is recognized as SbtFile when passed explicitly") { + TestInputs(os.rel / "build.sbt" -> "").fromRoot { root => + val elements = Inputs.validateArgs( + Seq((root / "build.sbt").toString), + root, + download = _ => Right(Array.emptyByteArray), + stdinOpt = None, + acceptFds = false, + enableMarkdown = false + )(using ScalaCliInvokeData.dummy) + elements match { + case Seq(Right(Seq(f: SbtFile))) => + assert(f.path == root / "build.sbt") + case _ => fail(s"Unexpected elements: $elements") + } + } + } + + test("sbt file is picked up from directory scan") { + TestInputs(os.rel / "build.sbt" -> "").fromRoot { root => + val dir = Directory(root) + val singles = dir.singleFilesFromDirectory(enableMarkdown = false) + val sbtFiles = singles.collect { case f: SbtFile => f } + assert(sbtFiles.nonEmpty) + assert(sbtFiles.head.path == root / "build.sbt") + } + } + test("URLs with query parameters") { val urlBase = "https://gist.githubusercontent.com/USER/hash/raw/hash" diff --git a/modules/cli/src/main/scala/scala/cli/commands/fmt/Fmt.scala b/modules/cli/src/main/scala/scala/cli/commands/fmt/Fmt.scala index 96c24b96db..f08c247048 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/fmt/Fmt.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/fmt/Fmt.scala @@ -5,7 +5,7 @@ import caseapp.core.help.HelpFormat import dependency.* import scala.build.Logger -import scala.build.input.{ProjectScalaFile, Script, SourceScalaFile} +import scala.build.input.{ProjectScalaFile, SbtFile, Script, SourceScalaFile} import scala.build.internal.{Constants, ExternalBinaryParams, FetchExternalBinary, Runner} import scala.build.internals.ConsoleUtils.ScalaCliConsole.warnPrefix import scala.cli.CurrentParams @@ -53,7 +53,7 @@ object Fmt extends ScalaCommand[FmtOptions] { if args.all.isEmpty then (Seq(os.pwd), os.pwd, None) else { val i = options.shared.inputs(args.all).orExit(logger) - type FormattableSourceFile = Script | SourceScalaFile | ProjectScalaFile + type FormattableSourceFile = Script | SourceScalaFile | ProjectScalaFile | SbtFile val s = i.sourceFiles().collect { case sc: FormattableSourceFile => sc.path } (s, i.workspace, Some(i)) } diff --git a/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala b/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala index 78b53b5bfb..c13b1e15ce 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala @@ -600,6 +600,7 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers { case s: ScalaFile => fwd(s.path.toString) case s: Script => fwd(s.path.toString) case s: MarkdownFile => fwd(s.path.toString) + case _: SbtFile => "" case s: OnDisk => fwd(s.path.toString) case s => s.getClass.getName }.filter(_.nonEmpty).distinct diff --git a/modules/integration/src/test/scala/scala/cli/integration/CompileTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/CompileTestDefinitions.scala index 61efd2a41f..b4e5fdfeaa 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/CompileTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/CompileTestDefinitions.scala @@ -972,4 +972,17 @@ abstract class CompileTestDefinitions } } } + + test("sbt file in directory does not break compile") { + TestInputs( + os.rel / "Main.scala" -> + """object Main { + | def main(args: Array[String]): Unit = println("Hello") + |} + |""".stripMargin, + os.rel / "build.sbt" -> """name := "my-project"""" + ).fromRoot { root => + os.proc(TestUtil.cli, "compile", extraOptions, ".").call(cwd = root) + } + } } diff --git a/modules/integration/src/test/scala/scala/cli/integration/FixTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/FixTestDefinitions.scala index b7e060246f..c0355bd4cb 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/FixTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/FixTestDefinitions.scala @@ -68,6 +68,30 @@ abstract class FixTestDefinitions } } + test("sbt file in directory does not break fix") { + TestInputs( + os.rel / "Main.scala" -> + """object Main { + | def main(args: Array[String]): Unit = println("Hello") + |} + |""".stripMargin, + os.rel / "build.sbt" -> """name := "my-project"""", + os.rel / scalafixConfFileName -> + """rules = [ + | RedundantSyntax + |] + |""".stripMargin + ).fromRoot { root => + os.proc( + TestUtil.cli, + "--power", + "fix", + ".", + extraOptions + ).call(cwd = root) + } + } + def filterDebugOutputs(output: String): String = output .linesIterator diff --git a/modules/integration/src/test/scala/scala/cli/integration/FmtTests.scala b/modules/integration/src/test/scala/scala/cli/integration/FmtTests.scala index ace4fef4b4..afd439f1bc 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/FmtTests.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/FmtTests.scala @@ -232,4 +232,35 @@ class FmtTests extends ScalaCliSuite { expect(updatedContent == expectedSimpleInputsFormattedContent) } } + + val sbtUnformattedContent: String = + """val message = "hello" + |""".stripMargin + val expectedSbtFormattedContent: String = noCrLf { + """val message = "hello" + |""".stripMargin + } + val sbtInputs: TestInputs = TestInputs( + os.rel / confFileName -> + s"""|version = "${Constants.defaultScalafmtVersion}" + |runner.dialect = scala213 + |""".stripMargin, + os.rel / "build.sbt" -> sbtUnformattedContent + ) + + test("sbt file is formatted when passed explicitly") { + sbtInputs.fromRoot { root => + os.proc(TestUtil.cli, "fmt", "build.sbt").call(cwd = root) + val updatedContent = noCrLf(os.read(root / "build.sbt")) + expect(updatedContent == expectedSbtFormattedContent) + } + } + + test("sbt file is formatted when directory is passed") { + sbtInputs.fromRoot { root => + os.proc(TestUtil.cli, "fmt", ".").call(cwd = root) + val updatedContent = noCrLf(os.read(root / "build.sbt")) + expect(updatedContent == expectedSbtFormattedContent) + } + } } diff --git a/modules/integration/src/test/scala/scala/cli/integration/PackageTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/PackageTestDefinitions.scala index 784a94c356..97c20ec286 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/PackageTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/PackageTestDefinitions.scala @@ -1601,4 +1601,26 @@ abstract class PackageTestDefinitions extends ScalaCliSuite with TestScalaVersio } } } + + test("sbt file in directory does not break package") { + val message = "Hello from package" + TestInputs( + os.rel / "Main.scala" -> + s"""object Main { + | def main(args: Array[String]): Unit = println("$message") + |} + |""".stripMargin, + os.rel / "build.sbt" -> """name := "my-project"""" + ).fromRoot { root => + os.proc(TestUtil.cli, "--power", "package", extraOptions, ".").call( + cwd = root, + stdin = os.Inherit, + stdout = os.Inherit + ) + val launcher = root / (if Properties.isWin then "Main.bat" else "Main") + expect(os.isFile(launcher)) + val output = TestUtil.maybeUseBash(launcher)(cwd = root).out.trim() + expect(output == message) + } + } } diff --git a/modules/integration/src/test/scala/scala/cli/integration/RunTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/RunTestDefinitions.scala index 1d3517e2d6..6bb62f249e 100755 --- a/modules/integration/src/test/scala/scala/cli/integration/RunTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/RunTestDefinitions.scala @@ -2510,4 +2510,19 @@ abstract class RunTestDefinitions processes.foreach { case (p, _) => expect(p.exitCode() == 0) } } } + + test("sbt file in directory does not break run") { + val message = "Hello from run" + TestInputs( + os.rel / "Main.scala" -> + s"""object Main { + | def main(args: Array[String]): Unit = println("$message") + |} + |""".stripMargin, + os.rel / "build.sbt" -> """name := "my-project"""" + ).fromRoot { root => + val output = os.proc(TestUtil.cli, extraOptions, ".").call(cwd = root).out.trim() + expect(output == message) + } + } } From 9c5b53300d624730bcb0592ea07c5acf6217a581 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:47:57 +0100 Subject: [PATCH 23/64] Bump dorny/test-reporter from 2 to 3 in the github-actions group (#4198) Bumps the github-actions group with 1 update: [dorny/test-reporter](https://github.com/dorny/test-reporter). Updates `dorny/test-reporter` from 2 to 3 - [Release notes](https://github.com/dorny/test-reporter/releases) - [Changelog](https://github.com/dorny/test-reporter/blob/main/CHANGELOG.md) - [Commits](https://github.com/dorny/test-reporter/compare/v2...v3) --- updated-dependencies: - dependency-name: dorny/test-reporter dependency-version: '3' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/test-report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-report.yml b/.github/workflows/test-report.yml index db7bb8da78..e70e63aa25 100644 --- a/.github/workflows/test-report.yml +++ b/.github/workflows/test-report.yml @@ -14,7 +14,7 @@ jobs: report: runs-on: ubuntu-latest steps: - - uses: dorny/test-reporter@v2 + - uses: dorny/test-reporter@v3 with: artifact: /test-results-(.*)/ name: 'Test report $1' From a9d64843ba683675c113b4de82119fe58468d0c9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 01:00:31 +0000 Subject: [PATCH 24/64] Bump picomatch from 2.3.1 to 2.3.2 in /website Bumps [picomatch](https://github.com/micromatch/picomatch) from 2.3.1 to 2.3.2. - [Release notes](https://github.com/micromatch/picomatch/releases) - [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md) - [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2) --- updated-dependencies: - dependency-name: picomatch dependency-version: 2.3.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- website/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/website/yarn.lock b/website/yarn.lock index 8b7e1fbe4e..aff4a30ee9 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -7391,9 +7391,9 @@ picocolors@^1.0.0, picocolors@^1.1.1: integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3, picomatch@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" - integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + version "2.3.2" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.2.tgz#5a942915e26b372dc0f0e6753149a16e6b1c5601" + integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA== pkg-dir@^7.0.0: version "7.0.0" From 7c3508aeac6779933b82f5a6e2b69d43b0222dda Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:17:10 +0200 Subject: [PATCH 25/64] Bump node-forge from 1.3.3 to 1.4.0 in /website (#4202) Bumps [node-forge](https://github.com/digitalbazaar/forge) from 1.3.3 to 1.4.0. - [Changelog](https://github.com/digitalbazaar/forge/blob/main/CHANGELOG.md) - [Commits](https://github.com/digitalbazaar/forge/compare/v1.3.3...v1.4.0) --- updated-dependencies: - dependency-name: node-forge dependency-version: 1.4.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- website/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/website/yarn.lock b/website/yarn.lock index aff4a30ee9..003d03bf93 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -7074,9 +7074,9 @@ node-emoji@^2.1.0: skin-tone "^2.0.0" node-forge@^1: - version "1.3.3" - resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.3.tgz#0ad80f6333b3a0045e827ac20b7f735f93716751" - integrity sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg== + version "1.4.0" + resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.4.0.tgz#1c7b7d8bdc2d078739f58287d589d903a11b2fc2" + integrity sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ== node-releases@^2.0.27: version "2.0.27" From 60e41acaffbfbc9e198c61fe2ae6d02a81e2ffc1 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Tue, 31 Mar 2026 09:54:02 +0200 Subject: [PATCH 26/64] Bump Scala 3 Next to 3.8.3 (#4204) --- project/deps/package.mill | 4 ++-- website/docs/reference/cli-options.md | 2 +- .../reference/scala-command/cli-options.md | 2 +- .../scala-command/runner-specification.md | 18 +++++++++--------- website/docs/reference/scala-versions.md | 3 ++- 5 files changed, 15 insertions(+), 14 deletions(-) diff --git a/project/deps/package.mill b/project/deps/package.mill index 57f6494f0f..246354df80 100644 --- a/project/deps/package.mill +++ b/project/deps/package.mill @@ -19,8 +19,8 @@ object Scala { def scala3Lts = s"$scala3LtsPrefix.7" // the LTS version currently used in the build def runnerScala3 = scala3Lts def scala3NextPrefix = "3.8" - def scala3Next = s"$scala3NextPrefix.2" // the newest/next version of Scala - def scala3NextAnnounced = scala3Next // the newest/next version of Scala that's been announced + def scala3Next = s"$scala3NextPrefix.3" // the newest/next version of Scala + def scala3NextAnnounced = s"$scala3NextPrefix.2" // the newest/next version of Scala that's been announced def scala3NextRc = "3.8.3-RC3" // the latest RC version of Scala Next def scala3NextRcAnnounced = "3.8.3-RC2" // the latest announced RC version of Scala Next diff --git a/website/docs/reference/cli-options.md b/website/docs/reference/cli-options.md index 6d0ec34942..1beb1411e8 100644 --- a/website/docs/reference/cli-options.md +++ b/website/docs/reference/cli-options.md @@ -1623,7 +1623,7 @@ Available in commands: Aliases: `-S`, `--scala` -Set the Scala version (3.8.2 by default) +Set the Scala version (3.8.3 by default) ### `--scala-binary-version` diff --git a/website/docs/reference/scala-command/cli-options.md b/website/docs/reference/scala-command/cli-options.md index 2c2f7e14e4..3a2faa185f 100644 --- a/website/docs/reference/scala-command/cli-options.md +++ b/website/docs/reference/scala-command/cli-options.md @@ -1066,7 +1066,7 @@ Aliases: `-S`, `--scala` `MUST have` per Scala Runner specification -Set the Scala version (3.8.2 by default) +Set the Scala version (3.8.3 by default) ### `--scala-binary-version` diff --git a/website/docs/reference/scala-command/runner-specification.md b/website/docs/reference/scala-command/runner-specification.md index 65024b3e0a..7ab255dab7 100644 --- a/website/docs/reference/scala-command/runner-specification.md +++ b/website/docs/reference/scala-command/runner-specification.md @@ -92,7 +92,7 @@ Aliases: `-P` ,`--plugin` **--scala-version** -Set the Scala version (3.8.2 by default) +Set the Scala version (3.8.3 by default) Aliases: `-S` ,`--scala` @@ -883,7 +883,7 @@ Aliases: `-P` ,`--plugin` **--scala-version** -Set the Scala version (3.8.2 by default) +Set the Scala version (3.8.3 by default) Aliases: `-S` ,`--scala` @@ -1492,7 +1492,7 @@ Aliases: `-P` ,`--plugin` **--scala-version** -Set the Scala version (3.8.2 by default) +Set the Scala version (3.8.3 by default) Aliases: `-S` ,`--scala` @@ -2115,7 +2115,7 @@ Aliases: `-P` ,`--plugin` **--scala-version** -Set the Scala version (3.8.2 by default) +Set the Scala version (3.8.3 by default) Aliases: `-S` ,`--scala` @@ -2763,7 +2763,7 @@ Aliases: `-P` ,`--plugin` **--scala-version** -Set the Scala version (3.8.2 by default) +Set the Scala version (3.8.3 by default) Aliases: `-S` ,`--scala` @@ -3399,7 +3399,7 @@ Aliases: `-P` ,`--plugin` **--scala-version** -Set the Scala version (3.8.2 by default) +Set the Scala version (3.8.3 by default) Aliases: `-S` ,`--scala` @@ -4054,7 +4054,7 @@ Aliases: `-P` ,`--plugin` **--scala-version** -Set the Scala version (3.8.2 by default) +Set the Scala version (3.8.3 by default) Aliases: `-S` ,`--scala` @@ -4781,7 +4781,7 @@ Aliases: `-P` ,`--plugin` **--scala-version** -Set the Scala version (3.8.2 by default) +Set the Scala version (3.8.3 by default) Aliases: `-S` ,`--scala` @@ -5758,7 +5758,7 @@ Aliases: `-P` ,`--plugin` **--scala-version** -Set the Scala version (3.8.2 by default) +Set the Scala version (3.8.3 by default) Aliases: `-S` ,`--scala` diff --git a/website/docs/reference/scala-versions.md b/website/docs/reference/scala-versions.md index 1e2128a3b2..d7a2595a25 100644 --- a/website/docs/reference/scala-versions.md +++ b/website/docs/reference/scala-versions.md @@ -38,5 +38,6 @@ it is recommended to update scala-cli. | 1.11.0 | 3.7.4 | 2.13.18 | 2.12.21 | | 1.12.0 | 3.8.0 | 2.13.18 | 2.12.21 | | 1.12.1 - 1.12.2 | 3.8.1 | 2.13.18 | 2.12.21 | -| 1.12.3 - current | 3.8.2 | 2.13.18 | 2.12.21 | +| 1.12.3 - 1.12.5 | 3.8.2 | 2.13.18 | 2.12.21 | +| 1.13.0 - current | 3.8.3 | 2.13.18 | 2.12.21 | From 5d8f05118dd6ddd031804c3401cd9cacc2787f5d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 08:37:12 +0000 Subject: [PATCH 27/64] Bump brace-expansion from 1.1.12 to 1.1.13 in /website (#4205) Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 1.1.12 to 1.1.13. - [Release notes](https://github.com/juliangruber/brace-expansion/releases) - [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.12...v1.1.13) --- updated-dependencies: - dependency-name: brace-expansion dependency-version: 1.1.13 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- website/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/website/yarn.lock b/website/yarn.lock index 003d03bf93..1d2940f3cd 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -3569,9 +3569,9 @@ boxen@^7.0.0: wrap-ansi "^8.1.0" brace-expansion@^1.1.7: - version "1.1.12" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.12.tgz#ab9b454466e5a8cc3a187beaad580412a9c5b843" - integrity sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg== + version "1.1.13" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.13.tgz#d37875c01dc9eff988dd49d112a57cb67b54efe6" + integrity sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w== dependencies: balanced-match "^1.0.0" concat-map "0.0.1" From 8a04f12c376c742ab21fb4ff81d604f6f6e49af9 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Tue, 31 Mar 2026 10:54:48 +0200 Subject: [PATCH 28/64] Add `java-test-runner` module to support running tests with pure Java (#4197) * Add `java-test-runner` module to support running tests with pure Java * Add more logs for debugging `java-test-runner` rather than silencing exceptions * Add more logs for debugging `test-runner`, mirroring `java-test-runner` approach * Adjust log verbosity in `test-runner`/`java-test-runner` * Only hardcode pure Java frameworks in `java-test-runner` --- build.mill | 25 +- .../src/main/scala/scala/build/Build.scala | 3 +- .../scala/scala/build/internal/Runner.scala | 30 +- .../build/tests/FrameworkDiscoveryTests.scala | 4 +- .../build/tests/JavaTestRunnerTests.scala | 51 +++ .../scala/scala/cli/commands/test/Test.scala | 12 +- .../cli/exportCmd/MillProjectDescriptor.scala | 7 +- .../cli/exportCmd/SbtProjectDescriptor.scala | 7 +- .../cli/integration/RunTestsDefault.scala | 26 ++ .../cli/integration/TestTestsDefault.scala | 34 ++ .../build/testrunner/JavaAsmTestRunner.java | 329 ++++++++++++++++++ .../testrunner/JavaDynamicTestRunner.java | 162 +++++++++ .../build/testrunner/JavaFrameworkUtils.java | 201 +++++++++++ .../build/testrunner/JavaTestLogger.java | 29 ++ .../build/testrunner/JavaTestRunner.java | 82 +++++ .../main/scala/scala/build/Artifacts.scala | 12 + .../scala/build/options/BuildOptions.scala | 8 +- .../build/testrunner/AsmTestRunner.scala | 110 ++++-- .../build/testrunner/DynamicTestRunner.scala | 27 +- .../build/testrunner/FrameworkUtils.scala | 146 +++++--- .../scala/build/testrunner/TestRunner.scala | 10 +- 21 files changed, 1197 insertions(+), 118 deletions(-) create mode 100644 modules/build/src/test/scala/scala/build/tests/JavaTestRunnerTests.scala create mode 100644 modules/java-test-runner/src/main/java/scala/build/testrunner/JavaAsmTestRunner.java create mode 100644 modules/java-test-runner/src/main/java/scala/build/testrunner/JavaDynamicTestRunner.java create mode 100644 modules/java-test-runner/src/main/java/scala/build/testrunner/JavaFrameworkUtils.java create mode 100644 modules/java-test-runner/src/main/java/scala/build/testrunner/JavaTestLogger.java create mode 100644 modules/java-test-runner/src/main/java/scala/build/testrunner/JavaTestRunner.java diff --git a/build.mill b/build.mill index 83484c3460..17bbedff6d 100644 --- a/build.mill +++ b/build.mill @@ -112,6 +112,8 @@ object runner extends Cross[Runner](Scala.runnerScalaVersions) with CrossScalaDefaultToRunner object `test-runner` extends Cross[TestRunner](Scala.runnerScalaVersions) with CrossScalaDefaultToRunner +object `java-test-runner` extends JavaTestRunner + with LocatedInModules object `tasty-lib` extends Cross[TastyLib](Scala.scala3MainVersions) with CrossScalaDefaultToInternal @@ -452,12 +454,18 @@ trait Core extends ScalaCliCrossSbtModule val runnerMainClass = build.runner(crossScalaVersion) .mainClass() .getOrElse(sys.error("No main class defined for runner")) + val javaTestRunnerMainClass = `java-test-runner` + .mainClass() + .getOrElse(sys.error("No main class defined for java-test-runner")) val detailedVersionValue = if (`local-repo`.developingOnStubModules) s"""Some("${vcsState()}")""" else "None" val testRunnerOrganization = `test-runner`(crossScalaVersion) .pomSettings() .organization + val javaTestRunnerOrganization = `java-test-runner` + .pomSettings() + .organization val code = s"""package scala.build.internal | @@ -479,6 +487,11 @@ trait Core extends ScalaCliCrossSbtModule | def testRunnerVersion = "${`test-runner`(crossScalaVersion).publishVersion()}" | def testRunnerMainClass = "$testRunnerMainClass" | + | def javaTestRunnerOrganization = "$javaTestRunnerOrganization" + | def javaTestRunnerModuleName = "${`java-test-runner`.artifactName()}" + | def javaTestRunnerVersion = "${`java-test-runner`.publishVersion()}" + | def javaTestRunnerMainClass = "$javaTestRunnerMainClass" + | | def runnerOrganization = "${build.runner(crossScalaVersion).pomSettings().organization}" | def runnerModuleName = "${build.runner(crossScalaVersion).artifactName()}" | def runnerVersion = "${build.runner(crossScalaVersion).publishVersion()}" @@ -1323,6 +1336,16 @@ trait TestRunner extends CrossSbtModule override def mainClass: T[Option[String]] = Some("scala.build.testrunner.DynamicTestRunner") } +trait JavaTestRunner extends JavaModule + with ScalaCliPublishModule + with LocatedInModules { + override def mvnDeps: T[Seq[Dep]] = super.mvnDeps() ++ Seq( + Deps.asm, + Deps.testInterface + ) + override def mainClass: T[Option[String]] = Some("scala.build.testrunner.JavaDynamicTestRunner") +} + trait TastyLib extends ScalaCliCrossSbtModule with ScalaCliPublishModule with ScalaCliScalafixModule @@ -1357,7 +1380,7 @@ object `local-repo` extends LocalRepo { def developingOnStubModules = false override def stubsModules: Seq[PublishLocalNoFluff] = - Seq(runner(Scala.runnerScala3), `test-runner`(Scala.runnerScala3)) + Seq(runner(Scala.runnerScala3), `test-runner`(Scala.runnerScala3), `java-test-runner`) override def version: T[String] = runner(Scala.runnerScala3).publishVersion() } diff --git a/modules/build/src/main/scala/scala/build/Build.scala b/modules/build/src/main/scala/scala/build/Build.scala index 04816c8299..1a52b9bc6d 100644 --- a/modules/build/src/main/scala/scala/build/Build.scala +++ b/modules/build/src/main/scala/scala/build/Build.scala @@ -1105,8 +1105,7 @@ object Build { either { val options0 = - // FIXME: don't add Scala to pure Java test builds (need to add pure Java test runner) - if sources.hasJava && !sources.hasScala && scope != Scope.Test + if sources.hasJava && !sources.hasScala then options.copy( scalaOptions = options.scalaOptions.copy( diff --git a/modules/build/src/main/scala/scala/build/internal/Runner.scala b/modules/build/src/main/scala/scala/build/internal/Runner.scala index c02df7ee8b..47d70c89a4 100644 --- a/modules/build/src/main/scala/scala/build/internal/Runner.scala +++ b/modules/build/src/main/scala/scala/build/internal/Runner.scala @@ -15,12 +15,15 @@ import scala.build.Logger import scala.build.errors.* import scala.build.internals.EnvVar import scala.build.testrunner.FrameworkUtils.* -import scala.build.testrunner.{AsmTestRunner, TestRunner} +import scala.build.testrunner.{AsmTestRunner, Logger as TestRunnerLogger, TestRunner} import scala.scalanative.testinterface.adapter.TestAdapter as ScalaNativeTestAdapter import scala.util.{Failure, Properties, Success} object Runner { + private def toTestRunnerLogger(logger: Logger): TestRunnerLogger = + TestRunnerLogger(logger.verbosity) + def maybeExec( commandName: String, command: Seq[String], @@ -346,15 +349,18 @@ object Runner { frameworks: Seq[Framework], requireTests: Boolean, args: Seq[String], - parentInspector: AsmTestRunner.ParentInspector + parentInspector: AsmTestRunner.ParentInspector, + logger: Logger ): Either[NoTestsRun, Boolean] = frameworks .flatMap { framework => + val trLogger = toTestRunnerLogger(logger) val taskDefs = AsmTestRunner.taskDefs( classPath, keepJars = false, framework.fingerprints().toIndexedSeq, - parentInspector + parentInspector, + trLogger ).toArray val runner = framework.runner(args.toArray, Array(), null) @@ -380,16 +386,22 @@ object Runner { parentInspector: AsmTestRunner.ParentInspector, logger: Logger ): Either[NoTestFrameworkFoundError, Seq[String]] = { + val trLogger = toTestRunnerLogger(logger) logger.debug("Looking for test framework services on the classpath...") val foundFrameworkServices = - AsmTestRunner.findFrameworkServices(classPath) + AsmTestRunner.findFrameworkServices(classPath, trLogger) .map(_.replace('/', '.').replace('\\', '.')) logger.debug(s"Found ${foundFrameworkServices.length} test framework services.") if foundFrameworkServices.nonEmpty then logger.debug(s" - ${foundFrameworkServices.mkString("\n - ")}") logger.debug("Looking for more test frameworks on the classpath...") val foundFrameworks = - AsmTestRunner.findFrameworks(classPath, TestRunner.commonTestFrameworks, parentInspector) + AsmTestRunner.findFrameworks( + classPath, + TestRunner.commonTestFrameworks, + parentInspector, + trLogger + ) .map(_.replace('/', '.').replace('\\', '.')) logger.debug(s"Found ${foundFrameworks.length} additional test frameworks") if foundFrameworks.nonEmpty then @@ -444,7 +456,7 @@ object Runner { logger.debug(s"JS tests class path: $classPath") - val parentInspector = new AsmTestRunner.ParentInspector(classPath) + val parentInspector = new AsmTestRunner.ParentInspector(classPath, toTestRunnerLogger(logger)) val foundFrameworkNames: List[String] = predefinedTestFrameworks match { case f if f.nonEmpty => f.toList case Nil => value(frameworkNames(classPath, parentInspector, logger)).toList @@ -474,7 +486,7 @@ object Runner { ) if finalTestFrameworks.isEmpty then Left(new NoFrameworkFoundByBridgeError) - else runTests(classPath, finalTestFrameworks, requireTests, args, parentInspector) + else runTests(classPath, finalTestFrameworks, requireTests, args, parentInspector, logger) } finally if adapter != null then adapter.close() @@ -492,7 +504,7 @@ object Runner { logger.debug("Preparing to run tests with Scala Native...") logger.debug(s"Native tests class path: $classPath") - val parentInspector = new AsmTestRunner.ParentInspector(classPath) + val parentInspector = new AsmTestRunner.ParentInspector(classPath, toTestRunnerLogger(logger)) val foundFrameworkNames: List[String] = predefinedTestFrameworks match { case f if f.nonEmpty => f.toList case Nil => value(frameworkNames(classPath, parentInspector, logger)).toList @@ -540,7 +552,7 @@ object Runner { ) if finalTestFrameworks.isEmpty then Left(new NoFrameworkFoundByNativeBridgeError) - else runTests(classPath, finalTestFrameworks, requireTests, args, parentInspector) + else runTests(classPath, finalTestFrameworks, requireTests, args, parentInspector, logger) } finally if adapter != null then adapter.close() diff --git a/modules/build/src/test/scala/scala/build/tests/FrameworkDiscoveryTests.scala b/modules/build/src/test/scala/scala/build/tests/FrameworkDiscoveryTests.scala index 862ed6a203..007c35341d 100644 --- a/modules/build/src/test/scala/scala/build/tests/FrameworkDiscoveryTests.scala +++ b/modules/build/src/test/scala/scala/build/tests/FrameworkDiscoveryTests.scala @@ -3,7 +3,7 @@ package scala.build.tests import java.nio.file.Files import scala.build.errors.NoFrameworkFoundByNativeBridgeError -import scala.build.testrunner.AsmTestRunner +import scala.build.testrunner.{AsmTestRunner, Logger as TestRunnerLogger} class FrameworkDiscoveryTests extends TestUtil.ScalaCliBuildSuite { @@ -25,7 +25,7 @@ class FrameworkDiscoveryTests extends TestUtil.ScalaCliBuildSuite { |""".stripMargin Files.writeString(serviceFile, content) - val found = AsmTestRunner.findFrameworkServices(Seq(dir)) + val found = AsmTestRunner.findFrameworkServices(Seq(dir), TestRunnerLogger(0)) assertEquals( found.sorted, Seq("munit.Framework", "munit.native.Framework"), diff --git a/modules/build/src/test/scala/scala/build/tests/JavaTestRunnerTests.scala b/modules/build/src/test/scala/scala/build/tests/JavaTestRunnerTests.scala new file mode 100644 index 0000000000..3cfc8f155e --- /dev/null +++ b/modules/build/src/test/scala/scala/build/tests/JavaTestRunnerTests.scala @@ -0,0 +1,51 @@ +package scala.build.tests + +import com.eed3si9n.expecty.Expecty.assert as expect + +import scala.build.options.* + +class JavaTestRunnerTests extends TestUtil.ScalaCliBuildSuite { + + private def makeOptions( + scalaVersionOpt: Option[MaybeScalaVersion], + addTestRunner: Boolean + ): BuildOptions = + BuildOptions( + scalaOptions = ScalaOptions( + scalaVersion = scalaVersionOpt + ), + internalDependencies = InternalDependenciesOptions( + addTestRunnerDependencyOpt = Some(addTestRunner) + ) + ) + + test("pure Java build has no scalaParams") { + val opts = makeOptions(Some(MaybeScalaVersion.none), addTestRunner = false) + val params = opts.scalaParams.toOption.flatten + expect(params.isEmpty, "Pure Java build should have no scalaParams") + } + + test("Scala build has scalaParams") { + val opts = makeOptions(None, addTestRunner = false) + val params = opts.scalaParams.toOption.flatten + expect(params.isDefined, "Scala build should have scalaParams") + } + + test("pure Java test build gets addJvmJavaTestRunner=true in Artifacts params") { + val opts = makeOptions(Some(MaybeScalaVersion.none), addTestRunner = true) + val isJava = opts.scalaParams.toOption.flatten.isEmpty + expect(isJava, "Expected pure Java build to have no scalaParams") + } + + test("Scala test build gets addJvmTestRunner=true in Artifacts params") { + val opts = makeOptions(None, addTestRunner = true) + val isJava = opts.scalaParams.toOption.flatten.isEmpty + expect(!isJava, "Expected Scala build to have scalaParams") + } + + test("mixed Scala+Java build still gets Scala test runner") { + val opts = makeOptions(None, addTestRunner = true) + val isJava = opts.scalaParams.toOption.flatten.isEmpty + expect(!isJava, "Mixed Scala+Java build should still use Scala test runner") + } +} diff --git a/modules/cli/src/main/scala/scala/cli/commands/test/Test.scala b/modules/cli/src/main/scala/scala/cli/commands/test/Test.scala index a236a5a93c..7342d8c074 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/test/Test.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/test/Test.scala @@ -12,7 +12,7 @@ import scala.build.errors.{BuildException, CompositeBuildException} import scala.build.internal.{Constants, Runner} import scala.build.internals.ConsoleUtils.ScalaCliConsole import scala.build.options.{BuildOptions, JavaOpt, Platform, Scope} -import scala.build.testrunner.AsmTestRunner +import scala.build.testrunner.{AsmTestRunner, Logger as TestRunnerLogger} import scala.cli.CurrentParams import scala.cli.commands.run.Run import scala.cli.commands.setupide.SetupIde @@ -256,11 +256,16 @@ object Test extends ScalaCommand[TestOptions] { testOnly.map(to => s"--test-only=$to").toSeq ++ Seq("--") ++ args + val testRunnerMainClass = + if build.artifacts.hasJavaTestRunner + then Constants.javaTestRunnerMainClass + else Constants.testRunnerMainClass + Runner.runJvm( build.options.javaHome().value.javaCommand, build.options.javaOptions.javaOpts.toSeq.map(_.value.value), classPath, - Constants.testRunnerMainClass, + testRunnerMainClass, extraArgs, logger, allowExecve = allowExecve @@ -274,7 +279,8 @@ object Test extends ScalaCommand[TestOptions] { // https://github.com/VirtusLab/scala-cli/issues/426 if classPath0.exists(_.contains("zio-test")) && !classPath0.exists(_.contains("zio-test-sbt")) then { - val parentInspector = new AsmTestRunner.ParentInspector(classPath) + val parentInspector = + new AsmTestRunner.ParentInspector(classPath, TestRunnerLogger(logger.verbosity)) Runner.frameworkNames(classPath, parentInspector, logger) match { case Right(f) => f.headOption case Left(_) => diff --git a/modules/cli/src/main/scala/scala/cli/exportCmd/MillProjectDescriptor.scala b/modules/cli/src/main/scala/scala/cli/exportCmd/MillProjectDescriptor.scala index f0f08646cd..82aa32653f 100644 --- a/modules/cli/src/main/scala/scala/cli/exportCmd/MillProjectDescriptor.scala +++ b/modules/cli/src/main/scala/scala/cli/exportCmd/MillProjectDescriptor.scala @@ -9,7 +9,7 @@ import scala.build.errors.BuildException import scala.build.internal.Constants import scala.build.internal.Runner.frameworkNames import scala.build.options.{BuildOptions, Platform, ScalaJsOptions, ScalaNativeOptions, Scope} -import scala.build.testrunner.AsmTestRunner +import scala.build.testrunner.{AsmTestRunner, Logger as TestRunnerLogger} import scala.build.{Logger, Sources} import scala.cli.ScalaCli @@ -137,8 +137,9 @@ final case class MillProjectDescriptor( logger.debug(exception.message) Seq.empty } - val parentInspector = new AsmTestRunner.ParentInspector(testClassPath) - val frameworkName0 = options.testOptions.frameworks.headOption.orElse { + val parentInspector = + new AsmTestRunner.ParentInspector(testClassPath, TestRunnerLogger(logger.verbosity)) + val frameworkName0 = options.testOptions.frameworks.headOption.orElse { frameworkNames(testClassPath, parentInspector, logger).toOption .flatMap(_.headOption) // TODO: handle multiple frameworks here } diff --git a/modules/cli/src/main/scala/scala/cli/exportCmd/SbtProjectDescriptor.scala b/modules/cli/src/main/scala/scala/cli/exportCmd/SbtProjectDescriptor.scala index 0637903252..c56f0bc0a5 100644 --- a/modules/cli/src/main/scala/scala/cli/exportCmd/SbtProjectDescriptor.scala +++ b/modules/cli/src/main/scala/scala/cli/exportCmd/SbtProjectDescriptor.scala @@ -17,7 +17,7 @@ import scala.build.options.{ Scope, ShadowingSeq } -import scala.build.testrunner.AsmTestRunner +import scala.build.testrunner.{AsmTestRunner, Logger as TestRunnerLogger} import scala.build.{Logger, Positioned, Sources} import scala.cli.ScalaCli @@ -258,8 +258,9 @@ final case class SbtProjectDescriptor( Seq.empty } - val parentInspector = new AsmTestRunner.ParentInspector(testClassPath) - val frameworkName0 = options.testOptions.frameworks.headOption.orElse { + val parentInspector = + new AsmTestRunner.ParentInspector(testClassPath, TestRunnerLogger(logger.verbosity)) + val frameworkName0 = options.testOptions.frameworks.headOption.orElse { frameworkNames(testClassPath, parentInspector, logger).toOption .flatMap(_.headOption) // TODO: handle multiple frameworks here } diff --git a/modules/integration/src/test/scala/scala/cli/integration/RunTestsDefault.scala b/modules/integration/src/test/scala/scala/cli/integration/RunTestsDefault.scala index 2464a67e19..aff40fa39a 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/RunTestsDefault.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/RunTestsDefault.scala @@ -236,4 +236,30 @@ class RunTestsDefault extends RunTestDefinitions expect(res.err.trim().contains(expectedWarning)) } } + + for { + buildServerOptions <- Seq(Nil, Seq("--server=false")) + buildServerDesc = + if buildServerOptions.isEmpty then "with build server" else "without build server" + } + test(s"pure Java run has no Scala on classpath $buildServerDesc") { + TestInputs( + os.rel / "Main.java" -> + """public class Main { + | public static void main(String[] args) { + | try { + | Class.forName("scala.Predef"); + | throw new RuntimeException("Scala should not be on the classpath"); + | } catch (ClassNotFoundException e) { + | System.out.println("No Scala on classpath!"); + | } + | } + |} + |""".stripMargin + ).fromRoot { root => + val res = + os.proc(TestUtil.cli, "run", buildServerOptions, extraOptions, ".").call(cwd = root) + expect(res.out.text().contains("No Scala on classpath!")) + } + } } diff --git a/modules/integration/src/test/scala/scala/cli/integration/TestTestsDefault.scala b/modules/integration/src/test/scala/scala/cli/integration/TestTestsDefault.scala index 391494c909..c38e260b44 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/TestTestsDefault.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/TestTestsDefault.scala @@ -98,4 +98,38 @@ class TestTestsDefault extends TestTestDefinitions with TestDefault { expect(err.countOccurrences(expectedWarning) == 1) } } + + for { + buildServerOptions <- Seq(Nil, Seq("--server=false")) + buildServerDesc = + if buildServerOptions.isEmpty then "with build server" else "without build server" + } + test(s"pure Java test with JUnit has no Scala on classpath $buildServerDesc") { + TestInputs( + os.rel / "test" / "MyTests.java" -> + """//> using test.dep junit:junit:4.13.2 + |//> using test.dep com.novocode:junit-interface:0.11 + |import org.junit.Test; + |import static org.junit.Assert.assertEquals; + | + |public class MyTests { + | @Test + | public void foo() { + | try { + | Class.forName("scala.Predef"); + | throw new AssertionError("Scala should not be on the classpath"); + | } catch (ClassNotFoundException e) { + | // expected + | } + | assertEquals(4, 2 + 2); + | System.out.println("No Scala on classpath!"); + | } + |} + |""".stripMargin + ).fromRoot { root => + val res = + os.proc(TestUtil.cli, "test", extraOptions, buildServerOptions, ".").call(cwd = root) + expect(res.out.text().contains("No Scala on classpath!")) + } + } } diff --git a/modules/java-test-runner/src/main/java/scala/build/testrunner/JavaAsmTestRunner.java b/modules/java-test-runner/src/main/java/scala/build/testrunner/JavaAsmTestRunner.java new file mode 100644 index 0000000000..8c3df378e1 --- /dev/null +++ b/modules/java-test-runner/src/main/java/scala/build/testrunner/JavaAsmTestRunner.java @@ -0,0 +1,329 @@ +package scala.build.testrunner; + +import org.objectweb.asm.*; +import sbt.testing.*; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Stream; +import java.util.zip.*; + +public class JavaAsmTestRunner { + + public static class ParentInspector { + private final List classPath; + private final JavaTestLogger logger; + private final ConcurrentHashMap> cache = new ConcurrentHashMap<>(); + + public ParentInspector(List classPath, JavaTestLogger logger) { + this.classPath = classPath; + this.logger = logger; + } + + private List parents(String className) { + return cache.computeIfAbsent(className, name -> { + byte[] byteCode = findInClassPath(classPath, name + ".class", logger); + if (byteCode == null) return Collections.emptyList(); + TestClassChecker checker = new TestClassChecker(); + ClassReader reader = new ClassReader(byteCode); + reader.accept(checker, 0); + return checker.getImplements(); + }); + } + + public List allParents(String className) { + List result = new ArrayList<>(); + Set done = new HashSet<>(); + Deque todo = new ArrayDeque<>(); + todo.add(className); + while (!todo.isEmpty()) { + String current = todo.poll(); + if (!done.add(current)) continue; + result.add(current); + todo.addAll(parents(current)); + } + return result; + } + } + + public static Optional matchFingerprints( + String className, + InputStream byteCodeStream, + List fingerprints, + ParentInspector parentInspector, + ClassLoader loader, + JavaTestLogger logger + ) throws IOException { + TestClassChecker checker = new TestClassChecker(); + ClassReader reader = new ClassReader(byteCodeStream); + reader.accept(checker, 0); + + boolean isModule = className.endsWith("$"); + boolean hasPublicConstructors = checker.getPublicConstructorCount() > 0; + boolean definitelyNoTests = checker.isAbstract() || + checker.isInterface() || + checker.getPublicConstructorCount() > 1 || + isModule == hasPublicConstructors; + + if (definitelyNoTests) return Optional.empty(); + + for (Fingerprint fp : fingerprints) { + if (fp instanceof SubclassFingerprint) { + SubclassFingerprint sf = (SubclassFingerprint) fp; + if (sf.isModule() != isModule) continue; + String superName = sf.superclassName().replace('.', '/'); + if (parentInspector.allParents(checker.getName()).contains(superName)) { + return Optional.of(fp); + } + } else if (fp instanceof AnnotatedFingerprint) { + AnnotatedFingerprint af = (AnnotatedFingerprint) fp; + if (af.isModule() != isModule) continue; + // Use classloader-based reflection for annotation matching (proven approach) + if (loader != null) { + try { + String rawName = className.replace('/', '.').replace('\\', '.'); + String clsNameForLoad = rawName.endsWith("$") ? rawName.substring(0, rawName.length() - 1) : rawName; + Class cls = loader.loadClass(clsNameForLoad); + Optional result = + JavaFrameworkUtils.matchFingerprints(loader, cls, new Fingerprint[]{fp}, logger); + if (result.isPresent()) return Optional.of(fp); + } catch (ClassNotFoundException | NoClassDefFoundError | + UnsupportedClassVersionError | IncompatibleClassChangeError e) { + // Expected: class may not be loadable during scanning + logger.debug( + "Could not load class for annotation matching: " + className + " (" + e + ")"); + } + } + } + } + return Optional.empty(); + } + + public static List findFrameworkServices(List classPath, JavaTestLogger logger) { + List result = new ArrayList<>(); + byte[] content = findInClassPath(classPath, "META-INF/services/sbt.testing.Framework", logger); + if (content != null) { + parseServiceFileContent(new String(content, StandardCharsets.UTF_8), result); + } + return result; + } + + private static void parseServiceFileContent(String content, List result) { + for (String line : content.split("[\r\n]+")) { + String trimmed = line.trim(); + if (!trimmed.isEmpty() && !trimmed.startsWith("#")) { + result.add(trimmed); + } + } + } + + public static List findFrameworks( + List classPath, + List preferredClasses, + ParentInspector parentInspector, + JavaTestLogger logger + ) { + List result = new ArrayList<>(); + // first check preferred classes + for (String preferred : preferredClasses) { + String resourceName = preferred.replace('.', '/') + ".class"; + byte[] bytes = findInClassPath(classPath, resourceName, logger); + if (bytes != null) { + TestClassChecker checker = new TestClassChecker(); + new ClassReader(bytes).accept(checker, 0); + if (!checker.isAbstract() && checker.getPublicConstructorCount() == 1) { + String internalName = preferred.replace('.', '/'); + if (parentInspector.allParents(internalName).contains("sbt/testing/Framework")) { + result.add(internalName); + } + } + } + } + if (!result.isEmpty()) return result; + + // scan all classes in classpath + for (Map.Entry entry : listClassesByteCode(classPath, true, logger).entrySet()) { + String name = entry.getKey(); + if (name.contains("module-info")) continue; + TestClassChecker checker = new TestClassChecker(); + new ClassReader(entry.getValue()).accept(checker, 0); + if (!checker.isAbstract() && checker.getPublicConstructorCount() == 1) { + if (parentInspector.allParents(name).contains("sbt/testing/Framework")) { + result.add(name); + } + } + } + return result; + } + + public static List taskDefs( + List classPath, + boolean keepJars, + List fingerprints, + ParentInspector parentInspector, + ClassLoader loader, + JavaTestLogger logger + ) { + List result = new ArrayList<>(); + for (Map.Entry entry : listClassesByteCode(classPath, keepJars, logger).entrySet()) { + String name = entry.getKey(); + if (name.contains("module-info")) continue; + try { + Optional fp = matchFingerprints( + name, + new ByteArrayInputStream(entry.getValue()), + fingerprints, + parentInspector, + loader, + logger + ); + if (fp.isPresent()) { + String stripped = name.endsWith("$") ? name.substring(0, name.length() - 1) : name; + String clsName = stripped.replace('/', '.').replace('\\', '.'); + result.add(new TaskDef(clsName, fp.get(), false, new Selector[]{new SuiteSelector()})); + } + } catch (IOException e) { + logger.debug("Could not read bytecode for " + name + ": " + e.getMessage()); + } + } + return result; + } + + private static Map listClassesByteCode( + List classPath, boolean keepJars, JavaTestLogger logger + ) { + Map result = new LinkedHashMap<>(); + for (Path entry : classPath) { + result.putAll(listClassesByteCode(entry, keepJars, logger)); + } + return result; + } + + private static Map listClassesByteCode( + Path entry, boolean keepJars, JavaTestLogger logger + ) { + Map result = new LinkedHashMap<>(); + if (Files.isDirectory(entry)) { + try (Stream stream = Files.walk(entry, Integer.MAX_VALUE)) { + stream.filter(p -> p.getFileName().toString().endsWith(".class")) + .forEach(p -> { + String rel = entry.relativize(p).toString().replace('\\', '/'); + String name = rel.endsWith(".class") ? rel.substring(0, rel.length() - 6) : rel; + try { + result.put(name, Files.readAllBytes(p)); + } catch (IOException e) { + logger.debug("Could not read class file " + p + ": " + e.getMessage()); + } + }); + } catch (IOException e) { + logger.log("Could not walk directory " + entry + ": " + e.getMessage()); + } + } else if (keepJars && Files.isRegularFile(entry)) { + byte[] buf = new byte[16384]; + try (ZipFile zf = new ZipFile(entry.toFile())) { + Enumeration entries = zf.entries(); + while (entries.hasMoreElements()) { + ZipEntry ze = entries.nextElement(); + if (!ze.getName().endsWith(".class")) continue; + String name = ze.getName(); + name = name.substring(0, name.length() - 6); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (InputStream is = zf.getInputStream(ze)) { + int read; + while ((read = is.read(buf)) >= 0) { + baos.write(buf, 0, read); + } + } + result.put(name, baos.toByteArray()); + } + } catch (IOException e) { + logger.log("Could not read JAR " + entry + ": " + e.getMessage()); + } + } + return result; + } + + static byte[] findInClassPath(List classPath, String name, JavaTestLogger logger) { + for (Path entry : classPath) { + byte[] found = findInClassPathEntry(entry, name, logger); + if (found != null) return found; + } + return null; + } + + private static byte[] findInClassPathEntry(Path entry, String name, JavaTestLogger logger) { + if (Files.isDirectory(entry)) { + Path p = entry.resolve(name); + if (Files.isRegularFile(p)) { + try { + return Files.readAllBytes(p); + } catch (IOException e) { + logger.debug("Could not read " + p + ": " + e.getMessage()); + return null; + } + } + } else if (Files.isRegularFile(entry)) { + byte[] buf = new byte[16384]; + try (ZipFile zf = new ZipFile(entry.toFile())) { + ZipEntry ze = zf.getEntry(name); + if (ze == null) return null; + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (InputStream is = zf.getInputStream(ze)) { + int read; + while ((read = is.read(buf)) >= 0) { + baos.write(buf, 0, read); + } + } + return baos.toByteArray(); + } catch (IOException e) { + logger.debug("Could not read " + name + " from " + entry + ": " + e.getMessage()); + return null; + } + } + return null; + } + + public static class TestClassChecker extends ClassVisitor { + private String name; + private int publicConstructorCount = 0; + private boolean isInterface = false; + private boolean isAbstract = false; + private List implementsList = new ArrayList<>(); + + public TestClassChecker() { + super(Opcodes.ASM9); + } + + @Override + public void visit(int version, int access, String name, String signature, + String superName, String[] interfaces) { + this.name = name; + this.isInterface = (access & Opcodes.ACC_INTERFACE) != 0; + this.isAbstract = (access & Opcodes.ACC_ABSTRACT) != 0; + if (superName != null) implementsList.add(superName); + if (interfaces != null) { + for (String iface : interfaces) { + implementsList.add(iface); + } + } + } + + @Override + public MethodVisitor visitMethod(int access, String name, String descriptor, + String signature, String[] exceptions) { + if ("".equals(name) && (access & Opcodes.ACC_PUBLIC) != 0) { + publicConstructorCount++; + } + return null; + } + + public String getName() { return name; } + public int getPublicConstructorCount() { return publicConstructorCount; } + public boolean isInterface() { return isInterface; } + public boolean isAbstract() { return isAbstract; } + public List getImplements() { return implementsList; } + } +} diff --git a/modules/java-test-runner/src/main/java/scala/build/testrunner/JavaDynamicTestRunner.java b/modules/java-test-runner/src/main/java/scala/build/testrunner/JavaDynamicTestRunner.java new file mode 100644 index 0000000000..5de3b325d9 --- /dev/null +++ b/modules/java-test-runner/src/main/java/scala/build/testrunner/JavaDynamicTestRunner.java @@ -0,0 +1,162 @@ +package scala.build.testrunner; + +import sbt.testing.*; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.regex.Pattern; + +public class JavaDynamicTestRunner { + + /** + * Based on junit-interface GlobFilter.compileGlobPattern: + * https://github.com/sbt/junit-interface/blob/f8c6372ed01ce86f15393b890323d96afbe6d594/src/main/java/com/novocode/junit/GlobFilter.java#L37 + * + * Converts a glob expression (only * supported) into a regex Pattern. + */ + private static Pattern globPattern(String expr) { + String[] parts = expr.split("\\*", -1); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < parts.length; i++) { + if (i != 0) sb.append(".*"); + if (!parts[i].isEmpty()) sb.append(Pattern.quote(parts[i].replace("\n", "\\n"))); + } + return Pattern.compile(sb.toString()); + } + + public static void main(String[] args) { + List testFrameworks = new ArrayList<>(); + List remainingArgs = new ArrayList<>(); + boolean requireTests = false; + int verbosity = 0; + Optional testOnly = Optional.empty(); + + boolean pastDashDash = false; + for (String arg : args) { + if (pastDashDash) { + remainingArgs.add(arg); + } else if ("--".equals(arg)) { + pastDashDash = true; + } else if (arg.startsWith("--test-framework=")) { + testFrameworks.add(arg.substring("--test-framework=".length())); + } else if (arg.startsWith("--test-only=")) { + testOnly = Optional.of(arg.substring("--test-only=".length())); + } else if (arg.startsWith("--verbosity=")) { + try { + verbosity = Integer.parseInt(arg.substring("--verbosity=".length())); + } catch (NumberFormatException e) { + System.err.println("Warning: malformed --verbosity value: " + arg); + } + } else if ("--require-tests".equals(arg)) { + requireTests = true; + } else { + remainingArgs.add(arg); + } + } + + JavaTestLogger logger = new JavaTestLogger(verbosity, System.err); + + if (!testFrameworks.isEmpty()) { + logger.debug("Directly passed " + testFrameworks.size() + " test frameworks:\n - " + + String.join("\n - ", testFrameworks)); + } + + ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); + java.util.List classPath0 = JavaTestRunner.classPath(classLoader, logger); + + List frameworks; + if (!testFrameworks.isEmpty()) { + frameworks = new ArrayList<>(); + for (String fw : testFrameworks) { + try { + frameworks.add(JavaFrameworkUtils.loadFramework(classLoader, fw)); + } catch (Exception e) { + logger.error("Could not load test framework: " + fw); + logger.error(e.toString()); + System.exit(1); + } + } + } else { + List frameworkServices = JavaFrameworkUtils.findFrameworkServices(classLoader); + List scannedFrameworks = JavaFrameworkUtils.findFrameworks( + classPath0, classLoader, JavaTestRunner.commonTestFrameworks(), logger + ); + List toRun = JavaFrameworkUtils.getFrameworksToRun( + frameworkServices, scannedFrameworks, logger + ); + if (toRun.isEmpty()) { + if (verbosity >= 2) { + throw new RuntimeException("No test framework found"); + } else { + System.err.println("No test framework found"); + System.exit(1); + } + } + frameworks = toRun; + } + + String[] runnerArgs = remainingArgs.toArray(new String[0]); + final Optional testOnlyFinal = testOnly; + final boolean requireTestsFinal = requireTests; + + boolean anyFailed = false; + for (Framework framework : frameworks) { + logger.log("Running test framework: " + framework.name()); + Fingerprint[] fingerprints = framework.fingerprints(); + Runner runner = framework.runner(runnerArgs, new String[0], classLoader); + + List> classes = new ArrayList<>(); + for (String name : JavaFrameworkUtils.listClasses(classPath0, false, logger)) { + try { + classes.add(classLoader.loadClass(name)); + } catch (ClassNotFoundException | NoClassDefFoundError | + UnsupportedClassVersionError | IncompatibleClassChangeError e) { + // Expected: not every .class file on the classpath is loadable + logger.debug("Could not load class " + name + ": " + e); + } + } + + List taskDefs = new ArrayList<>(); + for (Class cls : classes) { + Optional fp = JavaFrameworkUtils.matchFingerprints( + classLoader, cls, fingerprints, logger + ); + if (!fp.isPresent()) continue; + String clsName = cls.getName().endsWith("$") + ? cls.getName().substring(0, cls.getName().length() - 1) + : cls.getName(); + if (testOnlyFinal.isPresent()) { + Pattern pat = globPattern(testOnlyFinal.get()); + if (!pat.matcher(clsName).matches()) continue; + } + taskDefs.add(new TaskDef(clsName, fp.get(), false, new Selector[]{new SuiteSelector()})); + } + + Task[] initialTasks = runner.tasks(taskDefs.toArray(new TaskDef[0])); + List events = JavaTestRunner.runTasks(Arrays.asList(initialTasks), System.out); + + boolean failed = events.stream().anyMatch(ev -> + ev.status() == Status.Error || + ev.status() == Status.Failure || + ev.status() == Status.Canceled + ); + + String doneMsg = runner.done(); + if (doneMsg != null && !doneMsg.isEmpty()) System.out.println(doneMsg); + + if (requireTestsFinal && events.isEmpty()) { + logger.error("Error: no tests were run for " + framework.name() + "."); + anyFailed = true; + } else if (failed) { + logger.error("Error: " + framework.name() + " tests failed."); + anyFailed = true; + } else { + logger.log(framework.name() + " tests ran successfully."); + } + } + + System.exit(anyFailed ? 1 : 0); + } +} diff --git a/modules/java-test-runner/src/main/java/scala/build/testrunner/JavaFrameworkUtils.java b/modules/java-test-runner/src/main/java/scala/build/testrunner/JavaFrameworkUtils.java new file mode 100644 index 0000000000..eb3b516b7e --- /dev/null +++ b/modules/java-test-runner/src/main/java/scala/build/testrunner/JavaFrameworkUtils.java @@ -0,0 +1,201 @@ +package scala.build.testrunner; + +import sbt.testing.*; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Modifier; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import java.util.stream.Stream; + +public class JavaFrameworkUtils { + + public static List findFrameworkServices(ClassLoader loader) { + List result = new ArrayList<>(); + ServiceLoader serviceLoader = ServiceLoader.load(Framework.class, loader); + for (Framework f : serviceLoader) { + result.add(f); + } + return result; + } + + public static Framework loadFramework(ClassLoader loader, String className) throws Exception { + Class cls = loader.loadClass(className); + return (Framework) cls.getConstructor().newInstance(); + } + + public static List findFrameworks( + List classPath, + ClassLoader loader, + List preferredClasses, + JavaTestLogger logger + ) { + Class frameworkCls = Framework.class; + List result = new ArrayList<>(); + Set seen = new LinkedHashSet<>(); + + // first try preferred classes, then scan classpath + List candidates = new ArrayList<>(preferredClasses); + for (String name : listClasses(classPath, true, logger)) { + if (!seen.contains(name)) { + candidates.add(name); + } + } + + for (String name : candidates) { + if (!seen.add(name)) continue; + Class cls; + try { + cls = loader.loadClass(name); + } catch (ClassNotFoundException | UnsupportedClassVersionError | + NoClassDefFoundError | IncompatibleClassChangeError e) { + // Expected: most classpath entries aren't test frameworks + continue; + } + if (!frameworkCls.isAssignableFrom(cls)) continue; + if (Modifier.isAbstract(cls.getModifiers())) continue; + long publicNoArgCtors = Arrays.stream(cls.getConstructors()) + .filter(c -> Modifier.isPublic(c.getModifiers()) && c.getParameterCount() == 0) + .count(); + if (publicNoArgCtors != 1) continue; + try { + Framework instance = (Framework) cls.getConstructor().newInstance(); + result.add(instance); + } catch (Exception e) { + logger.log("Could not instantiate framework " + name + ": " + e); + } + } + return result; + } + + public static Optional matchFingerprints( + ClassLoader loader, + Class cls, + Fingerprint[] fingerprints, + JavaTestLogger logger + ) { + boolean isModule = cls.getName().endsWith("$"); + long publicCtorCount = Arrays.stream(cls.getConstructors()) + .filter(c -> Modifier.isPublic(c.getModifiers())) + .count(); + boolean noPublicConstructors = publicCtorCount == 0; + boolean definitelyNoTests = Modifier.isAbstract(cls.getModifiers()) || + cls.isInterface() || + publicCtorCount > 1 || + isModule != noPublicConstructors; + if (definitelyNoTests) return Optional.empty(); + + for (Fingerprint fp : fingerprints) { + if (fp instanceof SubclassFingerprint) { + SubclassFingerprint sf = (SubclassFingerprint) fp; + if (sf.isModule() != isModule) continue; + try { + Class superCls = loader.loadClass(sf.superclassName()); + if (superCls.isAssignableFrom(cls)) return Optional.of(fp); + } catch (ClassNotFoundException e) { + logger.debug( + "Superclass not found for fingerprint matching: " + sf.superclassName()); + } + } else if (fp instanceof AnnotatedFingerprint) { + AnnotatedFingerprint af = (AnnotatedFingerprint) fp; + if (af.isModule() != isModule) continue; + try { + @SuppressWarnings("unchecked") + Class annotationCls = + (Class) loader.loadClass(af.annotationName()); + boolean matches = + cls.isAnnotationPresent(annotationCls) || + Arrays.stream(cls.getDeclaredMethods()) + .anyMatch(m -> m.isAnnotationPresent(annotationCls)) || + Arrays.stream(cls.getMethods()) + .anyMatch(m -> m.isAnnotationPresent(annotationCls) && + Modifier.isPublic(m.getModifiers())); + if (matches) return Optional.of(fp); + } catch (ClassNotFoundException e) { + logger.debug( + "Annotation class not found for fingerprint matching: " + af.annotationName()); + } + } + } + return Optional.empty(); + } + + public static List getFrameworksToRun( + List frameworkServices, + List frameworks, + JavaTestLogger logger + ) { + List all = new ArrayList<>(frameworkServices); + all.addAll(frameworks); + return getFrameworksToRun(all, logger); + } + + public static List getFrameworksToRun( + List allFrameworks, + JavaTestLogger logger + ) { + // dedup by name + Map byName = new LinkedHashMap<>(); + for (Framework f : allFrameworks) { + byName.putIfAbsent(f.name(), f); + } + List distinct = new ArrayList<>(byName.values()); + + // filter out frameworks that are superclasses of another framework in the list + List finalFrameworks = new ArrayList<>(); + for (Framework f1 : distinct) { + boolean isInherited = distinct.stream() + .filter(f2 -> f2 != f1) + .anyMatch(f2 -> f1.getClass().isAssignableFrom(f2.getClass())); + if (!isInherited) finalFrameworks.add(f1); + } + return finalFrameworks; + } + + public static List listClasses(List classPath, boolean keepJars, JavaTestLogger logger) { + List result = new ArrayList<>(); + for (Path entry : classPath) { + result.addAll(listClasses(entry, keepJars, logger)); + } + return result; + } + + public static List listClasses(Path entry, boolean keepJars, JavaTestLogger logger) { + List result = new ArrayList<>(); + if (Files.isDirectory(entry)) { + try (Stream stream = Files.walk(entry, Integer.MAX_VALUE)) { + stream.filter(p -> p.getFileName().toString().endsWith(".class")) + .map(entry::relativize) + .map(p -> { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < p.getNameCount(); i++) { + if (i > 0) sb.append("."); + sb.append(p.getName(i).toString()); + } + String name = sb.toString(); + return name.endsWith(".class") ? name.substring(0, name.length() - 6) : name; + }) + .forEach(result::add); + } catch (Exception e) { + logger.log("Could not walk directory " + entry + ": " + e.getMessage()); + } + } else if (keepJars && Files.isRegularFile(entry)) { + try (ZipFile zf = new ZipFile(entry.toFile())) { + Enumeration entries = zf.entries(); + while (entries.hasMoreElements()) { + ZipEntry ze = entries.nextElement(); + String name = ze.getName(); + if (name.endsWith(".class")) { + result.add(name.substring(0, name.length() - 6).replace("/", ".")); + } + } + } catch (Exception e) { + logger.log("Could not read JAR " + entry + ": " + e.getMessage()); + } + } + return result; + } +} diff --git a/modules/java-test-runner/src/main/java/scala/build/testrunner/JavaTestLogger.java b/modules/java-test-runner/src/main/java/scala/build/testrunner/JavaTestLogger.java new file mode 100644 index 0000000000..a02166de3b --- /dev/null +++ b/modules/java-test-runner/src/main/java/scala/build/testrunner/JavaTestLogger.java @@ -0,0 +1,29 @@ +package scala.build.testrunner; + +import java.io.PrintStream; + +public class JavaTestLogger { + private final int verbosity; + private final PrintStream out; + + public JavaTestLogger(int verbosity, PrintStream out) { + this.verbosity = verbosity; + this.out = out; + } + + public void error(String message) { + out.println(message); + } + + public void message(String message) { + if (verbosity >= 0) out.println(message); + } + + public void log(String message) { + if (verbosity >= 1) out.println(message); + } + + public void debug(String message) { + if (verbosity >= 2) out.println(message); + } +} diff --git a/modules/java-test-runner/src/main/java/scala/build/testrunner/JavaTestRunner.java b/modules/java-test-runner/src/main/java/scala/build/testrunner/JavaTestRunner.java new file mode 100644 index 0000000000..d09ecfdc3f --- /dev/null +++ b/modules/java-test-runner/src/main/java/scala/build/testrunner/JavaTestRunner.java @@ -0,0 +1,82 @@ +package scala.build.testrunner; + +import sbt.testing.*; + +import java.io.File; +import java.io.PrintStream; +import java.net.URLClassLoader; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Deque; +import java.util.List; + +public class JavaTestRunner { + + public static List commonTestFrameworks() { + // Only pure-Java-compatible frameworks belong here. + // Scala-only frameworks (munit, utest, ScalaCheck, ZIO Test, ScalaTest, weaver) + // live in the Scala test-runner's TestRunner.commonTestFrameworks instead. + return Arrays.asList("com.novocode.junit.JUnitFramework"); + } + + public static List classPath(ClassLoader loader, JavaTestLogger logger) { + List result = new ArrayList<>(); + collectClassPath(loader, result, logger); + return result; + } + + private static void collectClassPath(ClassLoader loader, List result, JavaTestLogger logger) { + if (loader == null) return; + if (loader instanceof URLClassLoader) { + URLClassLoader urlLoader = (URLClassLoader) loader; + for (java.net.URL url : urlLoader.getURLs()) { + if ("file".equals(url.getProtocol())) { + try { + result.add(Paths.get(url.toURI()).toAbsolutePath()); + } catch (Exception e) { + logger.debug( + "Could not convert URL to path: " + url + " (" + e.getMessage() + ")"); + } + } + } + } else if (loader.getClass().getName().equals("jdk.internal.loader.ClassLoaders$AppClassLoader")) { + String cp = System.getProperty("java.class.path", ""); + for (String entry : cp.split(File.pathSeparator)) { + if (!entry.isEmpty()) { + result.add(Paths.get(entry)); + } + } + } + collectClassPath(loader.getParent(), result, logger); + } + + public static List runTasks(List initialTasks, PrintStream out) { + Deque tasks = new ArrayDeque<>(initialTasks); + List events = new ArrayList<>(); + + sbt.testing.Logger logger = new sbt.testing.Logger() { + public boolean ansiCodesSupported() { return true; } + public void error(String msg) { out.println(msg); } + public void warn(String msg) { out.println(msg); } + public void info(String msg) { out.println(msg); } + public void debug(String msg) { out.println(msg); } + public void trace(Throwable t) { t.printStackTrace(out); } + }; + + EventHandler eventHandler = event -> events.add(event); + sbt.testing.Logger[] loggers = new sbt.testing.Logger[]{logger}; + + while (!tasks.isEmpty()) { + Task task = tasks.poll(); + Task[] newTasks = task.execute(eventHandler, loggers); + for (Task t : newTasks) { + tasks.add(t); + } + } + + return events; + } +} diff --git a/modules/options/src/main/scala/scala/build/Artifacts.scala b/modules/options/src/main/scala/scala/build/Artifacts.scala index bb430a5d81..c3cfa4c8ea 100644 --- a/modules/options/src/main/scala/scala/build/Artifacts.scala +++ b/modules/options/src/main/scala/scala/build/Artifacts.scala @@ -49,6 +49,7 @@ final case class Artifacts( extraSourceJars: Seq[os.Path], scalaOpt: Option[ScalaArtifacts], hasJvmRunner: Boolean, + hasJavaTestRunner: Boolean, resolution: Option[Resolution] ) { @@ -131,6 +132,7 @@ object Artifacts { jvmVersion: Int, addJvmRunner: Option[Boolean], addJvmTestRunner: Boolean, + addJvmJavaTestRunner: Boolean, addJmhDependencies: Option[String], extraRepositories: Seq[Repository], keepResolution: Boolean, @@ -189,11 +191,19 @@ object Artifacts { } else Nil + val jvmJavaTestRunnerDependencies = + if addJvmJavaTestRunner then + Seq( + dep"${Constants.javaTestRunnerOrganization}:${Constants.javaTestRunnerModuleName}:${Constants.javaTestRunnerVersion}" + ) + else Nil + val jmhDependencies = addJmhDependencies.toSeq .map(version => dep"${Constants.jmhOrg}:${Constants.jmhGeneratorBytecodeModule}:$version") val maybeSnapshotRepo = { val hasSnapshots = jvmTestRunnerDependencies.exists(_.version.endsWith("SNAPSHOT")) || + jvmJavaTestRunnerDependencies.exists(_.version.endsWith("SNAPSHOT")) || scalaArtifactsParamsOpt.flatMap(_.scalaNativeCliVersion).exists(_.endsWith("SNAPSHOT")) val hasNightlies = scalaArtifactsParamsOpt.exists(a => a.params.scalaVersion.endsWith("-NIGHTLY") || @@ -409,6 +419,7 @@ object Artifacts { val internalDependencies = jvmTestRunnerDependencies.map(Positioned.none) ++ + jvmJavaTestRunnerDependencies.map(Positioned.none) ++ scalaOpt.toSeq.flatMap(_.internalDependencies).map(Positioned.none) ++ jmhDependencies.map(Positioned.none) val updatedDependencies = dependencies ++ @@ -582,6 +593,7 @@ object Artifacts { extraSourceJars, scalaOpt, hasRunner, + addJvmJavaTestRunner, if (keepResolution) Some(fetchRes.resolution) else None ) } diff --git a/modules/options/src/main/scala/scala/build/options/BuildOptions.scala b/modules/options/src/main/scala/scala/build/options/BuildOptions.scala index 49b9a4cf5c..aa96da1d41 100644 --- a/modules/options/src/main/scala/scala/build/options/BuildOptions.scala +++ b/modules/options/src/main/scala/scala/build/options/BuildOptions.scala @@ -222,6 +222,10 @@ final case class BuildOptions( private def addJvmTestRunner: Boolean = platform.value == Platform.JVM && internalDependencies.addTestRunnerDependency + + private def addJvmJavaTestRunner: Boolean = + platform.value == Platform.JVM && + internalDependencies.addTestRunnerDependency private def addJsTestBridge: Option[String] = if (platform.value == Platform.JS && internalDependencies.addTestRunnerDependency) Some(scalaJsOptions.finalVersion) @@ -476,6 +480,7 @@ final case class BuildOptions( if (scalaArtifactsParamsOpt.isDefined) None else Some(false) // no runner in pure Java mode } + val isJavaBuild = scalaArtifactsParamsOpt.isEmpty val extraRepositories: Seq[Repository] = value(finalRepositories) val maybeArtifacts = Artifacts( scalaArtifactsParamsOpt = scalaArtifactsParamsOpt, @@ -490,7 +495,8 @@ final case class BuildOptions( fetchSources = classPathOptions.fetchSources.getOrElse(false), jvmVersion = javaHome().value.version, addJvmRunner = addRunnerDependency0, - addJvmTestRunner = isTests && addJvmTestRunner, + addJvmTestRunner = isTests && addJvmTestRunner && !isJavaBuild, + addJvmJavaTestRunner = isTests && addJvmJavaTestRunner && isJavaBuild, addJmhDependencies = jmhOptions.finalJmhVersion, extraRepositories = extraRepositories, keepResolution = internal.keepResolution, diff --git a/modules/test-runner/src/main/scala/scala/build/testrunner/AsmTestRunner.scala b/modules/test-runner/src/main/scala/scala/build/testrunner/AsmTestRunner.scala index 5cd3f75e09..0781a9285f 100644 --- a/modules/test-runner/src/main/scala/scala/build/testrunner/AsmTestRunner.scala +++ b/modules/test-runner/src/main/scala/scala/build/testrunner/AsmTestRunner.scala @@ -1,7 +1,7 @@ package scala.build.testrunner import org.objectweb.asm -import sbt.testing.* +import sbt.testing.{Logger as _, *} import java.io.{ByteArrayInputStream, ByteArrayOutputStream, InputStream} import java.nio.charset.StandardCharsets @@ -12,7 +12,7 @@ import scala.jdk.CollectionConverters.* object AsmTestRunner { - class ParentInspector(classPath: Seq[Path]) { + class ParentInspector(classPath: Seq[Path], logger: Logger) { private val cache = new ConcurrentHashMap[String, Seq[String]] @@ -21,7 +21,7 @@ object AsmTestRunner { case Some(value) => value case None => val byteCodeOpt = - findInClassPath(classPath, className + ".class") + findInClassPath(classPath, className + ".class", logger) .take(1) .toList .headOption @@ -99,7 +99,8 @@ object AsmTestRunner { private def listClassesByteCode( classPathEntry: Path, - keepJars: Boolean + keepJars: Boolean, + logger: Logger ): Iterator[(String, () => InputStream)] = if (Files.isDirectory(classPathEntry)) { var stream: java.util.stream.Stream[Path] = null @@ -118,6 +119,11 @@ object AsmTestRunner { .toVector // fully consume stream before closing it .iterator } + catch { + case e: Exception => + logger.log(s"Could not walk directory $classPathEntry: ${e.getMessage}") + Iterator.empty + } finally if (stream != null) stream.close() } else if (keepJars && Files.isRegularFile(classPathEntry)) { @@ -149,20 +155,36 @@ object AsmTestRunner { .toVector // fully consume ZipFile before closing it .iterator } + catch { + case e: Exception => + logger.log(s"Could not read JAR $classPathEntry: ${e.getMessage}") + Iterator.empty + } finally if (zf != null) zf.close() } else Iterator.empty private def listClassesByteCode( classPath: Seq[Path], - keepJars: Boolean + keepJars: Boolean, + logger: Logger ): Iterator[(String, () => InputStream)] = - classPath.iterator.flatMap(listClassesByteCode(_, keepJars)) + classPath.iterator.flatMap(listClassesByteCode(_, keepJars, logger)) - private def findInClassPath(classPathEntry: Path, name: String): Option[Array[Byte]] = + private def findInClassPath( + classPathEntry: Path, + name: String, + logger: Logger + ): Option[Array[Byte]] = if (Files.isDirectory(classPathEntry)) { val p = classPathEntry.resolve(name) - if (Files.isRegularFile(p)) Some(Files.readAllBytes(p)) + if (Files.isRegularFile(p)) + try Some(Files.readAllBytes(p)) + catch { + case e: java.io.IOException => + logger.debug(s"Could not read $p: ${e.getMessage}") + None + } else None } else if (Files.isRegularFile(classPathEntry)) { @@ -186,14 +208,23 @@ object AsmTestRunner { finally if (is != null) is.close() } } + catch { + case e: java.io.IOException => + logger.debug(s"Could not read $name from $classPathEntry: ${e.getMessage}") + None + } finally if (zf != null) zf.close() } else None - private def findInClassPath(classPath: Seq[Path], name: String): Iterator[Array[Byte]] = + private def findInClassPath( + classPath: Seq[Path], + name: String, + logger: Logger + ): Iterator[Array[Byte]] = classPath .iterator - .flatMap(findInClassPath(_, name).iterator) + .flatMap(findInClassPath(_, name, logger).iterator) /** Parse Java ServiceLoader format: one class name per line; # comments and empty lines ignored. */ @@ -205,26 +236,29 @@ object AsmTestRunner { .filter(line => line.nonEmpty && !line.startsWith("#")) .toSeq - def findFrameworkServices(classPath: Seq[Path]): Seq[String] = - findInClassPath(classPath, "META-INF/services/sbt.testing.Framework") + def findFrameworkServices(classPath: Seq[Path], logger: Logger): Seq[String] = + findInClassPath(classPath, "META-INF/services/sbt.testing.Framework", logger) .flatMap(b => parseServiceFileContent(new String(b, StandardCharsets.UTF_8))) .toSeq def findFrameworks( classPath: Seq[Path], preferredClasses: Seq[String], - parentInspector: ParentInspector + parentInspector: ParentInspector, + logger: Logger ): List[String] = { + // first check preferred classes val preferredClassesByteCode = preferredClasses .map(_.replace('.', '/')) .flatMap { name => - findInClassPath(classPath, name + ".class") + findInClassPath(classPath, name + ".class", logger) .map { b => def openStream() = new ByteArrayInputStream(b) (name, () => openStream()) } } - (preferredClassesByteCode.iterator ++ listClassesByteCode(classPath, true)) + // scan all classes in classpath + (preferredClassesByteCode.iterator ++ listClassesByteCode(classPath, true, logger)) .flatMap { case (moduleInfo, _) if moduleInfo.contains("module-info") => Iterator.empty case (name, is) => @@ -290,14 +324,21 @@ object AsmTestRunner { classPath: Seq[Path], keepJars: Boolean, fingerprints: Seq[Fingerprint], - parentInspector: ParentInspector + parentInspector: ParentInspector, + logger: Logger ): Iterator[TaskDef] = - listClassesByteCode(classPath, keepJars = keepJars) + listClassesByteCode(classPath, keepJars = keepJars, logger) .flatMap { case (name, is) => - matchFingerprints(name, is, fingerprints, parentInspector) - .map((name.stripSuffix("$"), _)) - .iterator + try + matchFingerprints(name, is, fingerprints, parentInspector) + .map((name.stripSuffix("$"), _)) + .iterator + catch { + case e: java.io.IOException => + logger.debug(s"Could not read bytecode for $name: ${e.getMessage}") + Iterator.empty + } } .map { case (clsName, fp) => @@ -311,16 +352,24 @@ object AsmTestRunner { def main(args: Array[String]): Unit = { - val classLoader = Thread.currentThread().getContextClassLoader - val classPath = TestRunner.classPath(classLoader) - - val parentCache = new ParentInspector(classPath) + val logger = Logger(0) - val frameworkClassName = findFrameworkServices(classPath).headOption // TODO handle multiple - .orElse(findFrameworks(classPath, TestRunner.commonTestFrameworks, parentCache).headOption) - .getOrElse(sys.error("No test framework found")) - .replace('/', '.') - .replace('\\', '.') + val classLoader = Thread.currentThread().getContextClassLoader + val classPath = TestRunner.classPath(classLoader, logger) + + val parentCache = new ParentInspector(classPath, logger) + + val frameworkClassName = + findFrameworkServices(classPath, logger).headOption // TODO handle multiple + .orElse(findFrameworks( + classPath, + TestRunner.commonTestFrameworks, + parentCache, + logger + ).headOption) + .getOrElse(sys.error("No test framework found")) + .replace('/', '.') + .replace('\\', '.') val framework = classLoader .loadClass(frameworkClassName) @@ -335,7 +384,8 @@ object AsmTestRunner { classPath, keepJars = false, framework.fingerprints().toIndexedSeq, - parentCache + parentCache, + logger ).toArray val runner = framework.runner(Array(), Array(), classLoader) diff --git a/modules/test-runner/src/main/scala/scala/build/testrunner/DynamicTestRunner.scala b/modules/test-runner/src/main/scala/scala/build/testrunner/DynamicTestRunner.scala index ae4b5b3f9b..4b43aea694 100644 --- a/modules/test-runner/src/main/scala/scala/build/testrunner/DynamicTestRunner.scala +++ b/modules/test-runner/src/main/scala/scala/build/testrunner/DynamicTestRunner.scala @@ -61,11 +61,18 @@ object DynamicTestRunner { t ) case h :: t if h.startsWith("--verbosity=") => + val v = + try h.stripPrefix("--verbosity=").toInt + catch { + case _: NumberFormatException => + System.err.println(s"Warning: malformed --verbosity value: $h") + 0 + } parse( testFrameworks, reverseTestArgs, requireTests, - h.stripPrefix("--verbosity=").toInt, + v, testOnly, t ) @@ -86,7 +93,7 @@ object DynamicTestRunner { ) val classLoader = Thread.currentThread().getContextClassLoader - val classPath0 = TestRunner.classPath(classLoader) + val classPath0 = TestRunner.classPath(classLoader, logger) val frameworks = Option(testFrameworks) .filter(_.nonEmpty) @@ -94,7 +101,8 @@ object DynamicTestRunner { .getOrElse { getFrameworksToRun( frameworkServices = findFrameworkServices(classLoader), - frameworks = findFrameworks(classPath0, classLoader, TestRunner.commonTestFrameworks) + frameworks = + findFrameworks(classPath0, classLoader, TestRunner.commonTestFrameworks, logger) )(logger) match { case f if f.nonEmpty => f case _ if verbosity >= 2 => sys.error("No test framework found") @@ -105,7 +113,16 @@ object DynamicTestRunner { } def classes = { val keepJars = false // look into dependencies, much slower - listClasses(classPath0, keepJars).map(name => classLoader.loadClass(name)) + listClasses(classPath0, keepJars, logger).flatMap { name => + try Iterator(classLoader.loadClass(name)) + catch { + case _: ClassNotFoundException | _: NoClassDefFoundError | + _: UnsupportedClassVersionError | _: IncompatibleClassChangeError => + // Expected: not every .class file on the classpath is loadable + logger.debug(s"Could not load class $name") + Iterator.empty + } + } } val out = System.out @@ -117,7 +134,7 @@ object DynamicTestRunner { val runner = framework.runner(args0.toArray, Array(), classLoader) def clsFingerprints = classes.flatMap { cls => - matchFingerprints(classLoader, cls, fingerprints) + matchFingerprints(classLoader, cls, fingerprints, logger) .map((cls, _)) .iterator } diff --git a/modules/test-runner/src/main/scala/scala/build/testrunner/FrameworkUtils.scala b/modules/test-runner/src/main/scala/scala/build/testrunner/FrameworkUtils.scala index 9dd92ab14c..7bb0e0a67d 100644 --- a/modules/test-runner/src/main/scala/scala/build/testrunner/FrameworkUtils.scala +++ b/modules/test-runner/src/main/scala/scala/build/testrunner/FrameworkUtils.scala @@ -87,46 +87,6 @@ object FrameworkUtils { getFrameworksToRun(allFrameworks = frameworkServices ++ frameworks)(logger) } - def listClasses(classPath: Seq[Path], keepJars: Boolean): Iterator[String] = - classPath.iterator.flatMap(listClasses(_, keepJars)) - - def listClasses(classPathEntry: Path, keepJars: Boolean): Iterator[String] = - if (Files.isDirectory(classPathEntry)) { - var stream: java.util.stream.Stream[Path] = null - try { - stream = Files.walk(classPathEntry, Int.MaxValue) - stream - .iterator - .asScala - .filter(_.getFileName.toString.endsWith(".class")) - .map(classPathEntry.relativize) - .map { p => - val count = p.getNameCount - (0 until count).map(p.getName).mkString(".") - } - .map(_.stripSuffix(".class")) - .toVector // fully consume stream before closing it - .iterator - } - finally if (stream != null) stream.close() - } - else if (keepJars && Files.isRegularFile(classPathEntry)) { - import java.util.zip._ - var zf: ZipFile = null - try { - zf = new ZipFile(classPathEntry.toFile) - zf.entries - .asScala - // FIXME Check if these are files too - .filter(_.getName.endsWith(".class")) - .map(ent => ent.getName.stripSuffix(".class").replace("/", ".")) - .toVector // full consume ZipFile before closing it - .iterator - } - finally if (zf != null) zf.close() - } - else Iterator.empty - def findFrameworkServices(loader: ClassLoader): Seq[Framework] = ServiceLoader.load(classOf[Framework], loader) .iterator() @@ -145,15 +105,18 @@ object FrameworkUtils { def findFrameworks( classPath: Seq[Path], loader: ClassLoader, - preferredClasses: Seq[String] + preferredClasses: Seq[String], + logger: Logger ): Seq[Framework] = { val frameworkCls = classOf[Framework] - (preferredClasses.iterator ++ listClasses(classPath, true)) + // first try preferred classes, then scan classpath + (preferredClasses.iterator ++ listClasses(classPath, true, logger)) .flatMap { name => val it: Iterator[Class[?]] = try Iterator(loader.loadClass(name)) catch { case _: ClassNotFoundException | _: UnsupportedClassVersionError | _: NoClassDefFoundError | _: IncompatibleClassChangeError => + // Expected: most classpath entries aren't test frameworks Iterator.empty } it @@ -179,17 +142,70 @@ object FrameworkUtils { Iterator(constructor.newInstance().asInstanceOf[Framework]) } catch { - case _: NoSuchMethodException => Iterator.empty + case e: Exception => + logger.log(s"Could not instantiate framework ${cls.getName}: $e") + Iterator.empty } } .toSeq } + def listClasses(classPath: Seq[Path], keepJars: Boolean, logger: Logger): Iterator[String] = + classPath.iterator.flatMap(listClasses(_, keepJars, logger)) + + def listClasses(classPathEntry: Path, keepJars: Boolean, logger: Logger): Iterator[String] = + if (Files.isDirectory(classPathEntry)) { + var stream: java.util.stream.Stream[Path] = null + try { + stream = Files.walk(classPathEntry, Int.MaxValue) + stream + .iterator + .asScala + .filter(_.getFileName.toString.endsWith(".class")) + .map(classPathEntry.relativize) + .map { p => + val count = p.getNameCount + (0 until count).map(p.getName).mkString(".") + } + .map(_.stripSuffix(".class")) + .toVector // fully consume stream before closing it + .iterator + } + catch { + case e: Exception => + logger.log(s"Could not walk directory $classPathEntry: ${e.getMessage}") + Iterator.empty + } + finally if (stream != null) stream.close() + } + else if (keepJars && Files.isRegularFile(classPathEntry)) { + import java.util.zip._ + var zf: ZipFile = null + try { + zf = new ZipFile(classPathEntry.toFile) + zf.entries + .asScala + // FIXME Check if these are files too + .filter(_.getName.endsWith(".class")) + .map(ent => ent.getName.stripSuffix(".class").replace("/", ".")) + .toVector // full consume ZipFile before closing it + .iterator + } + catch { + case e: Exception => + logger.log(s"Could not read JAR $classPathEntry: ${e.getMessage}") + Iterator.empty + } + finally if (zf != null) zf.close() + } + else Iterator.empty + // adapted from https://github.com/com-lihaoyi/mill/blob/ab4d61a50da24fb7fac97c4453dd8a770d8ac62b/scalalib/src/Lib.scala#L156-L172 def matchFingerprints( loader: ClassLoader, cls: Class[?], - fingerprints: Array[Fingerprint] + fingerprints: Array[Fingerprint], + logger: Logger ): Option[Fingerprint] = { val isModule = cls.getName.endsWith("$") val publicConstructorCount = cls.getConstructors.count(c => Modifier.isPublic(c.getModifiers)) @@ -203,21 +219,39 @@ object FrameworkUtils { else fingerprints.find { case f: SubclassFingerprint => - f.isModule == isModule && - loader.loadClass(f.superclassName()) - .isAssignableFrom(cls) + f.isModule == isModule && { + try + loader.loadClass(f.superclassName()) + .isAssignableFrom(cls) + catch { + case _: ClassNotFoundException => + logger.debug( + s"Superclass not found for fingerprint matching: ${f.superclassName()}" + ) + false + } + } case f: AnnotatedFingerprint => - val annotationCls = loader.loadClass(f.annotationName()) - .asInstanceOf[Class[Annotation]] - f.isModule == isModule && ( - cls.isAnnotationPresent(annotationCls) || - cls.getDeclaredMethods.exists(_.isAnnotationPresent(annotationCls)) || - cls.getMethods.exists { m => - m.isAnnotationPresent(annotationCls) && - Modifier.isPublic(m.getModifiers) + f.isModule == isModule && { + try { + val annotationCls = loader.loadClass(f.annotationName()) + .asInstanceOf[Class[Annotation]] + cls.isAnnotationPresent(annotationCls) || + cls.getDeclaredMethods.exists(_.isAnnotationPresent(annotationCls)) || + cls.getMethods.exists { m => + m.isAnnotationPresent(annotationCls) && + Modifier.isPublic(m.getModifiers) + } } - ) + catch { + case _: ClassNotFoundException => + logger.debug( + s"Annotation class not found for fingerprint matching: ${f.annotationName()}" + ) + false + } + } } } } diff --git a/modules/test-runner/src/main/scala/scala/build/testrunner/TestRunner.scala b/modules/test-runner/src/main/scala/scala/build/testrunner/TestRunner.scala index 33e9547bfb..10896c69e4 100644 --- a/modules/test-runner/src/main/scala/scala/build/testrunner/TestRunner.scala +++ b/modules/test-runner/src/main/scala/scala/build/testrunner/TestRunner.scala @@ -20,7 +20,7 @@ object TestRunner { "weaver.framework.CatsEffect" ) - def classPath(loader: ClassLoader): Seq[Path] = { + def classPath(loader: ClassLoader, logger: Logger): Seq[Path] = { def helper(loader: ClassLoader): LazyList[Path] = if (loader == null) LazyList.empty else { @@ -30,7 +30,9 @@ object TestRunner { .flatMap { case url if url.getProtocol == "file" => Seq(Paths.get(url.toURI).toAbsolutePath) - case _ => Nil // FIXME Warn about this + case url => + logger.debug(s"Skipping non-file URL in classloader: $url") + Nil } .to(LazyList) case cl if cl.getClass.getName == "jdk.internal.loader.ClassLoaders$AppClassLoader" => @@ -39,7 +41,9 @@ object TestRunner { .split(File.pathSeparator) .to(LazyList) .map(Paths.get(_)) - case _ => LazyList.empty // FIXME Warn about this + case cl => + logger.log(s"Unknown classloader type: ${cl.getClass.getName}") + LazyList.empty } paths #::: helper(loader.getParent) } From 738d82010581c1bc24086ce5c451e7df2f66f1ba Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:06:41 +0000 Subject: [PATCH 29/64] Bump @algolia/client-search in /website in the npm-dependencies group (#4206) Bumps the npm-dependencies group in /website with 1 update: [@algolia/client-search](https://github.com/algolia/algoliasearch-client-javascript). Updates `@algolia/client-search` from 5.49.2 to 5.50.0 - [Release notes](https://github.com/algolia/algoliasearch-client-javascript/releases) - [Changelog](https://github.com/algolia/algoliasearch-client-javascript/blob/main/CHANGELOG.md) - [Commits](https://github.com/algolia/algoliasearch-client-javascript/compare/5.49.2...5.50.0) --- updated-dependencies: - dependency-name: "@algolia/client-search" dependency-version: 5.50.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-dependencies ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- website/package.json | 2 +- website/yarn.lock | 54 ++++++++++++++++++++++---------------------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/website/package.json b/website/package.json index f47853b8d4..8f893b5eaa 100644 --- a/website/package.json +++ b/website/package.json @@ -14,7 +14,7 @@ "write-heading-ids": "docusaurus write-heading-ids" }, "dependencies": { - "@algolia/client-search": "^5.49.2", + "@algolia/client-search": "^5.50.0", "@docusaurus/core": "^3.9.2", "@docusaurus/plugin-content-docs": "^3.9.2", "@docusaurus/preset-classic": "^3.9.2", diff --git a/website/yarn.lock b/website/yarn.lock index 1d2940f3cd..459a6a1d25 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -92,10 +92,10 @@ resolved "https://registry.yarnpkg.com/@algolia/client-common/-/client-common-5.46.0.tgz#004ad40adbdc6da7e23e4ef4d7a0ff48422af012" integrity sha512-0emZTaYOeI9WzJi0TcNd2k3SxiN6DZfdWc2x2gHt855Jl9jPUOzfVTL6gTvCCrOlT4McvpDGg5nGO+9doEjjig== -"@algolia/client-common@5.49.2": - version "5.49.2" - resolved "https://registry.yarnpkg.com/@algolia/client-common/-/client-common-5.49.2.tgz#cb93f1ea9a60f7ffec65474e075afb52900f2434" - integrity sha512-bn0biLequn3epobCfjUqCxlIlurLr4RHu7RaE4trgN+RDcUq6HCVC3/yqq1hwbNYpVtulnTOJzcaxYlSr1fnuw== +"@algolia/client-common@5.50.0": + version "5.50.0" + resolved "https://registry.yarnpkg.com/@algolia/client-common/-/client-common-5.50.0.tgz#aec2bbd20fae474fb29eca3f8c83775dbd5d47de" + integrity sha512-emtOvR6dl3rX3sBJXXbofMNHU1qMQqQSWu319RMrNL5BWoBqyiq7y0Zn6cjJm7aGHV/Qbf+KCCYeWNKEMPI3BQ== "@algolia/client-insights@5.46.0": version "5.46.0" @@ -127,15 +127,15 @@ "@algolia/requester-fetch" "5.46.0" "@algolia/requester-node-http" "5.46.0" -"@algolia/client-search@5.46.0", "@algolia/client-search@^5.48.1", "@algolia/client-search@^5.49.2": - version "5.49.2" - resolved "https://registry.yarnpkg.com/@algolia/client-search/-/client-search-5.49.2.tgz#3c823ffaf333ce70fedfb3d45361661c8b227806" - integrity sha512-y1IOpG6OSmTpGg/CT0YBb/EAhR2nsC18QWp9Jy8HO9iGySpcwaTvs5kHa17daP3BMTwWyaX9/1tDTDQshZzXdg== +"@algolia/client-search@5.46.0", "@algolia/client-search@^5.48.1", "@algolia/client-search@^5.50.0": + version "5.50.0" + resolved "https://registry.yarnpkg.com/@algolia/client-search/-/client-search-5.50.0.tgz#c68668a63afbf27f7c2eb281aa51e87ac11d6973" + integrity sha512-Jc360x4yqb3eEg4OY4KEIdGePBxZogivKI+OGIU8aLXgAYPTECvzeOBc90312yHA1hr3AeRlAFl0rIc8lQaIrQ== dependencies: - "@algolia/client-common" "5.49.2" - "@algolia/requester-browser-xhr" "5.49.2" - "@algolia/requester-fetch" "5.49.2" - "@algolia/requester-node-http" "5.49.2" + "@algolia/client-common" "5.50.0" + "@algolia/requester-browser-xhr" "5.50.0" + "@algolia/requester-fetch" "5.50.0" + "@algolia/requester-node-http" "5.50.0" "@algolia/events@^4.0.1": version "4.0.1" @@ -179,12 +179,12 @@ dependencies: "@algolia/client-common" "5.46.0" -"@algolia/requester-browser-xhr@5.49.2": - version "5.49.2" - resolved "https://registry.yarnpkg.com/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.49.2.tgz#4493ed41dc84948693b34174ccc08ac5dfabd3dd" - integrity sha512-3UhYCcWX6fbtN8ABcxZlhaQEwXFh3CsFtARyyadQShHMPe3mJV9Wel4FpJTa+seugRkbezFz0tt6aPTZSYTBuA== +"@algolia/requester-browser-xhr@5.50.0": + version "5.50.0" + resolved "https://registry.yarnpkg.com/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.50.0.tgz#ae222fab442e78c8709f35555519e3fd43890557" + integrity sha512-bffIbUljAWnh/Ctu5uScORajuUavqmZ0ACYd1fQQeSSYA9NNN83ynO26pSc2dZRXpSK0fkc1//qSSFXMKGu+aw== dependencies: - "@algolia/client-common" "5.49.2" + "@algolia/client-common" "5.50.0" "@algolia/requester-fetch@5.46.0": version "5.46.0" @@ -193,12 +193,12 @@ dependencies: "@algolia/client-common" "5.46.0" -"@algolia/requester-fetch@5.49.2": - version "5.49.2" - resolved "https://registry.yarnpkg.com/@algolia/requester-fetch/-/requester-fetch-5.49.2.tgz#fd64e1ec726ffb63dce22112354119125c67a27e" - integrity sha512-G94VKSGbsr+WjsDDOBe5QDQ82QYgxvpxRGJfCHZBnYKYsy/jv9qGIDb93biza+LJWizQBUtDj7bZzp3QZyzhPQ== +"@algolia/requester-fetch@5.50.0": + version "5.50.0" + resolved "https://registry.yarnpkg.com/@algolia/requester-fetch/-/requester-fetch-5.50.0.tgz#cabe0e128122ed0f5493b4d1fb72bf26b5178fc5" + integrity sha512-y0EwNvPGvkM+yTAqqO6Gpt9wVGm3CLDtpLvNEiB3VGvN3WzfkjZGtLUsG/ru2kVJIIU7QcV0puuYgEpBeFxcJg== dependencies: - "@algolia/client-common" "5.49.2" + "@algolia/client-common" "5.50.0" "@algolia/requester-node-http@5.46.0": version "5.46.0" @@ -207,12 +207,12 @@ dependencies: "@algolia/client-common" "5.46.0" -"@algolia/requester-node-http@5.49.2": - version "5.49.2" - resolved "https://registry.yarnpkg.com/@algolia/requester-node-http/-/requester-node-http-5.49.2.tgz#ac71c6502b8ba8760d22afd3f38620934a28a68f" - integrity sha512-UuihBGHafG/ENsrcTGAn5rsOffrCIRuHMOsD85fZGLEY92ate+BMTUqxz60dv5zerh8ZumN4bRm8eW2z9L11jA== +"@algolia/requester-node-http@5.50.0": + version "5.50.0" + resolved "https://registry.yarnpkg.com/@algolia/requester-node-http/-/requester-node-http-5.50.0.tgz#733bfe889e35baa03f7986dd46587741e035eec3" + integrity sha512-xpwefe4fCOWnZgXCbkGpqQY6jgBSCf2hmgnySbyzZIccrv3SoashHKGPE4x6vVG+gdHrGciMTAcDo9HOZwH22Q== dependencies: - "@algolia/client-common" "5.49.2" + "@algolia/client-common" "5.50.0" "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.27.1": version "7.27.1" From dc28812ff16c3238526eef736ace5fd8e8e34db7 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Mon, 6 Apr 2026 12:32:46 +0200 Subject: [PATCH 30/64] Add `signed-by` support to Debian APT repository (#4207) --- build.mill | 27 +++++++++++++++++++++++++++ website/docs/_advanced_install.mdx | 3 ++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/build.mill b/build.mill index 17bbedff6d..1952ca2f2d 100644 --- a/build.mill +++ b/build.mill @@ -1872,6 +1872,33 @@ object ci extends Module { ) .call(cwd = debianDir, stdin = pgpPassphrase, stdout = inReleasePath) + // Export the public key as a binary (non-armored) keyring at the repo root so that + // users can reference it via `signed-by` in their APT sources. + // Binary format is required per https://wiki.debian.org/DebianRepository/UseThirdParty + val keyringPath = packagesDir / "scala-cli-archive-keyring.gpg" + os.proc("gpg", "--batch", "--yes", "--export", keyName) + .call(stdout = keyringPath) + + // Update the .list file to include the signed-by option pointing at the keyring. + // This scopes the key to this repository only, preventing the globally-trusted key + // security issue described in the Debian wiki. + os.write.over( + debianDir / "scala_cli_packages.list", + "deb [signed-by=/etc/apt/keyrings/scala-cli-archive-keyring.gpg] https://virtuslab.github.io/scala-cli-packages/debian ./\n" + ) + + // Also provide a DEB822 .sources file for users on modern Debian (apt modernize-sources). + // No Components field: this is a flat repository (Suites: ./). + // No Architectures field: avoids breaking when aarch64 packages are added later. + os.write.over( + debianDir / "scala_cli_packages.sources", + """Types: deb + |URIs: https://virtuslab.github.io/scala-cli-packages/debian + |Suites: ./ + |Signed-By: /etc/apt/keyrings/scala-cli-archive-keyring.gpg + |""".stripMargin + ) + commitChanges(s"Update Debian packages for $version", branch, packagesDir) } def updateChocolateyPackage(): Command[os.CommandResult] = Task.Command { diff --git a/website/docs/_advanced_install.mdx b/website/docs/_advanced_install.mdx index dd2c59abaf..914fd29f2e 100644 --- a/website/docs/_advanced_install.mdx +++ b/website/docs/_advanced_install.mdx @@ -83,7 +83,8 @@ scala-cli version Scala CLI can be installed via [apt](https://wiki.debian.org/Apt) packager tool. ```bash -curl -sS "https://virtuslab.github.io/scala-cli-packages/KEY.gpg" | sudo gpg --dearmor -o /etc/apt/trusted.gpg.d/scala-cli.gpg 2>/dev/null +sudo mkdir -p /etc/apt/keyrings +curl -sS "https://virtuslab.github.io/scala-cli-packages/scala-cli-archive-keyring.gpg" | sudo tee /etc/apt/keyrings/scala-cli-archive-keyring.gpg > /dev/null sudo curl -s --compressed -o /etc/apt/sources.list.d/scala_cli_packages.list "https://virtuslab.github.io/scala-cli-packages/debian/scala_cli_packages.list" sudo apt update sudo apt install scala-cli From 82c7d07b44186d55486e6bd05e1516107544c4dd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:32:38 +0000 Subject: [PATCH 31/64] Bump the npm-dependencies group in /website with 2 updates Bumps the npm-dependencies group in /website with 2 updates: [@algolia/client-search](https://github.com/algolia/algoliasearch-client-javascript) and [sass](https://github.com/sass/dart-sass). Updates `@algolia/client-search` from 5.50.0 to 5.50.1 - [Release notes](https://github.com/algolia/algoliasearch-client-javascript/releases) - [Changelog](https://github.com/algolia/algoliasearch-client-javascript/blob/main/CHANGELOG.md) - [Commits](https://github.com/algolia/algoliasearch-client-javascript/compare/5.50.0...5.50.1) Updates `sass` from 1.98.0 to 1.99.0 - [Release notes](https://github.com/sass/dart-sass/releases) - [Changelog](https://github.com/sass/dart-sass/blob/main/CHANGELOG.md) - [Commits](https://github.com/sass/dart-sass/compare/1.98.0...1.99.0) --- updated-dependencies: - dependency-name: "@algolia/client-search" dependency-version: 5.50.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: npm-dependencies - dependency-name: sass dependency-version: 1.99.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-dependencies ... Signed-off-by: dependabot[bot] --- website/package.json | 4 +-- website/yarn.lock | 62 ++++++++++++++++++++++---------------------- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/website/package.json b/website/package.json index 8f893b5eaa..07834eb4b6 100644 --- a/website/package.json +++ b/website/package.json @@ -14,7 +14,7 @@ "write-heading-ids": "docusaurus write-heading-ids" }, "dependencies": { - "@algolia/client-search": "^5.50.0", + "@algolia/client-search": "^5.50.1", "@docusaurus/core": "^3.9.2", "@docusaurus/plugin-content-docs": "^3.9.2", "@docusaurus/preset-classic": "^3.9.2", @@ -28,7 +28,7 @@ "react-dom": "^19.2.4", "react-loadable": "^5.5.0", "react-player": "^3.4.0", - "sass": "^1.98.0", + "sass": "^1.99.0", "search-insights": "^2.17.3", "@svta/cml-cta": "1.0.5", "@svta/cml-structured-field-values": "1.1.2", diff --git a/website/yarn.lock b/website/yarn.lock index 459a6a1d25..2ca96a57a2 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -92,10 +92,10 @@ resolved "https://registry.yarnpkg.com/@algolia/client-common/-/client-common-5.46.0.tgz#004ad40adbdc6da7e23e4ef4d7a0ff48422af012" integrity sha512-0emZTaYOeI9WzJi0TcNd2k3SxiN6DZfdWc2x2gHt855Jl9jPUOzfVTL6gTvCCrOlT4McvpDGg5nGO+9doEjjig== -"@algolia/client-common@5.50.0": - version "5.50.0" - resolved "https://registry.yarnpkg.com/@algolia/client-common/-/client-common-5.50.0.tgz#aec2bbd20fae474fb29eca3f8c83775dbd5d47de" - integrity sha512-emtOvR6dl3rX3sBJXXbofMNHU1qMQqQSWu319RMrNL5BWoBqyiq7y0Zn6cjJm7aGHV/Qbf+KCCYeWNKEMPI3BQ== +"@algolia/client-common@5.50.1": + version "5.50.1" + resolved "https://registry.yarnpkg.com/@algolia/client-common/-/client-common-5.50.1.tgz#c29b1b1f3e9098f36ee867413b2366893234b5bb" + integrity sha512-Hw52Fwapyk/7hMSV/fI4+s3H9MGZEUcRh4VphyXLAk2oLYdndVUkc6KBi0zwHSzwPAr+ZBwFPe2x6naUt9mZGw== "@algolia/client-insights@5.46.0": version "5.46.0" @@ -127,15 +127,15 @@ "@algolia/requester-fetch" "5.46.0" "@algolia/requester-node-http" "5.46.0" -"@algolia/client-search@5.46.0", "@algolia/client-search@^5.48.1", "@algolia/client-search@^5.50.0": - version "5.50.0" - resolved "https://registry.yarnpkg.com/@algolia/client-search/-/client-search-5.50.0.tgz#c68668a63afbf27f7c2eb281aa51e87ac11d6973" - integrity sha512-Jc360x4yqb3eEg4OY4KEIdGePBxZogivKI+OGIU8aLXgAYPTECvzeOBc90312yHA1hr3AeRlAFl0rIc8lQaIrQ== +"@algolia/client-search@5.46.0", "@algolia/client-search@^5.48.1", "@algolia/client-search@^5.50.1": + version "5.50.1" + resolved "https://registry.yarnpkg.com/@algolia/client-search/-/client-search-5.50.1.tgz#c49cb76919adc953000de358aaf14722f2c35c32" + integrity sha512-OteRb8WubcmEvU0YlMJwCXs3Q6xrdkb0v50/qZBJP1TF0CvujFZQM++9BjEkTER/Jr9wbPHvjSFKnbMta0b4dQ== dependencies: - "@algolia/client-common" "5.50.0" - "@algolia/requester-browser-xhr" "5.50.0" - "@algolia/requester-fetch" "5.50.0" - "@algolia/requester-node-http" "5.50.0" + "@algolia/client-common" "5.50.1" + "@algolia/requester-browser-xhr" "5.50.1" + "@algolia/requester-fetch" "5.50.1" + "@algolia/requester-node-http" "5.50.1" "@algolia/events@^4.0.1": version "4.0.1" @@ -179,12 +179,12 @@ dependencies: "@algolia/client-common" "5.46.0" -"@algolia/requester-browser-xhr@5.50.0": - version "5.50.0" - resolved "https://registry.yarnpkg.com/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.50.0.tgz#ae222fab442e78c8709f35555519e3fd43890557" - integrity sha512-bffIbUljAWnh/Ctu5uScORajuUavqmZ0ACYd1fQQeSSYA9NNN83ynO26pSc2dZRXpSK0fkc1//qSSFXMKGu+aw== +"@algolia/requester-browser-xhr@5.50.1": + version "5.50.1" + resolved "https://registry.yarnpkg.com/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.50.1.tgz#207659a24e8f7b933016457e36f58d7637578c18" + integrity sha512-XKdGGLikfrlK66ZSXh/vWcXZZ8Vg3byDFbJD8pwEvN1FoBRGxhxya476IY2ohoTymLa4qB5LBRlIa+2TLHx3Uw== dependencies: - "@algolia/client-common" "5.50.0" + "@algolia/client-common" "5.50.1" "@algolia/requester-fetch@5.46.0": version "5.46.0" @@ -193,12 +193,12 @@ dependencies: "@algolia/client-common" "5.46.0" -"@algolia/requester-fetch@5.50.0": - version "5.50.0" - resolved "https://registry.yarnpkg.com/@algolia/requester-fetch/-/requester-fetch-5.50.0.tgz#cabe0e128122ed0f5493b4d1fb72bf26b5178fc5" - integrity sha512-y0EwNvPGvkM+yTAqqO6Gpt9wVGm3CLDtpLvNEiB3VGvN3WzfkjZGtLUsG/ru2kVJIIU7QcV0puuYgEpBeFxcJg== +"@algolia/requester-fetch@5.50.1": + version "5.50.1" + resolved "https://registry.yarnpkg.com/@algolia/requester-fetch/-/requester-fetch-5.50.1.tgz#44de75a3371207ff82875d359d67cce77f203d9b" + integrity sha512-mBAU6WyVsDwhHyGM+nodt1/oebHxgvuLlOAoMGbj/1i6LygDHZWDgL1t5JEs37x9Aywv7ZGhqbM1GsfZ54sU6g== dependencies: - "@algolia/client-common" "5.50.0" + "@algolia/client-common" "5.50.1" "@algolia/requester-node-http@5.46.0": version "5.46.0" @@ -207,12 +207,12 @@ dependencies: "@algolia/client-common" "5.46.0" -"@algolia/requester-node-http@5.50.0": - version "5.50.0" - resolved "https://registry.yarnpkg.com/@algolia/requester-node-http/-/requester-node-http-5.50.0.tgz#733bfe889e35baa03f7986dd46587741e035eec3" - integrity sha512-xpwefe4fCOWnZgXCbkGpqQY6jgBSCf2hmgnySbyzZIccrv3SoashHKGPE4x6vVG+gdHrGciMTAcDo9HOZwH22Q== +"@algolia/requester-node-http@5.50.1": + version "5.50.1" + resolved "https://registry.yarnpkg.com/@algolia/requester-node-http/-/requester-node-http-5.50.1.tgz#f643e98b1e83e62bdd7cbc7324c6d3890d7508e5" + integrity sha512-qmo1LXrNKLHvJE6mdQbLnsZAoZvj7VyF2ft4xmbSGWI2WWm87fx/CjUX4kEExt4y0a6T6nEts6ofpUfH5TEE1A== dependencies: - "@algolia/client-common" "5.50.0" + "@algolia/client-common" "5.50.1" "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.27.1": version "7.27.1" @@ -8550,10 +8550,10 @@ sass-loader@^16.0.2: dependencies: neo-async "^2.6.2" -sass@^1.98.0: - version "1.98.0" - resolved "https://registry.yarnpkg.com/sass/-/sass-1.98.0.tgz#924ce85a3745ccaccd976262fdc1bc0c13aa8e57" - integrity sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A== +sass@^1.99.0: + version "1.99.0" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.99.0.tgz#ff9d1594da4886249dfaafabbeea2dea2dc74b26" + integrity sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q== dependencies: chokidar "^4.0.0" immutable "^5.1.5" From 9ddbdb295c3575514ec48238bb1e13063796cf1a Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Tue, 7 Apr 2026 19:43:36 +0200 Subject: [PATCH 32/64] Skip CI steps irrelevant to committed changes on PRs (#4208) --- .github/scripts/check-override-keywords.sh | 53 ++ .github/scripts/classify-changes.sh | 62 ++ .github/workflows/ci.yml | 678 ++++++++++++++++++--- DEV.md | 21 + 4 files changed, 727 insertions(+), 87 deletions(-) create mode 100755 .github/scripts/check-override-keywords.sh create mode 100755 .github/scripts/classify-changes.sh diff --git a/.github/scripts/check-override-keywords.sh b/.github/scripts/check-override-keywords.sh new file mode 100755 index 0000000000..51978b4a49 --- /dev/null +++ b/.github/scripts/check-override-keywords.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Checks the PR body for [test_*] override keywords. +# Inputs (env vars): EVENT_NAME, PR_BODY +# Outputs: writes override=true/false pairs to $GITHUB_OUTPUT and a summary table to $GITHUB_STEP_SUMMARY + +if [[ "$EVENT_NAME" != "pull_request" ]]; then + echo "Non-PR event, setting all overrides to true" + for override in test_all test_native test_integration test_docs test_format; do + echo "$override=true" >> "$GITHUB_OUTPUT" + done + exit 0 +fi + +TEST_ALL=false; TEST_NATIVE=false; TEST_INTEGRATION=false; TEST_DOCS=false; TEST_FORMAT=false + +check_override() { + local keyword="$1" + local var_name="$2" + if printf '%s' "$PR_BODY" | grep -qF "$keyword"; then + eval "$var_name=true" + echo "Override $keyword found" + fi +} + +check_override "[test_all]" "TEST_ALL" +check_override "[test_native]" "TEST_NATIVE" +check_override "[test_integration]" "TEST_INTEGRATION" +check_override "[test_docs]" "TEST_DOCS" +check_override "[test_format]" "TEST_FORMAT" + +echo "Override keywords:" +echo " test_all=$TEST_ALL" +echo " test_native=$TEST_NATIVE" +echo " test_integration=$TEST_INTEGRATION" +echo " test_docs=$TEST_DOCS" +echo " test_format=$TEST_FORMAT" + +echo "test_all=$TEST_ALL" >> "$GITHUB_OUTPUT" +echo "test_native=$TEST_NATIVE" >> "$GITHUB_OUTPUT" +echo "test_integration=$TEST_INTEGRATION" >> "$GITHUB_OUTPUT" +echo "test_docs=$TEST_DOCS" >> "$GITHUB_OUTPUT" +echo "test_format=$TEST_FORMAT" >> "$GITHUB_OUTPUT" + +echo "## Override keywords" >> "$GITHUB_STEP_SUMMARY" +echo "| Keyword | Active |" >> "$GITHUB_STEP_SUMMARY" +echo "|---------|--------|" >> "$GITHUB_STEP_SUMMARY" +echo "| [test_all] | $TEST_ALL |" >> "$GITHUB_STEP_SUMMARY" +echo "| [test_native] | $TEST_NATIVE |" >> "$GITHUB_STEP_SUMMARY" +echo "| [test_integration] | $TEST_INTEGRATION |" >> "$GITHUB_STEP_SUMMARY" +echo "| [test_docs] | $TEST_DOCS |" >> "$GITHUB_STEP_SUMMARY" +echo "| [test_format] | $TEST_FORMAT |" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/scripts/classify-changes.sh b/.github/scripts/classify-changes.sh new file mode 100755 index 0000000000..3f241f1c39 --- /dev/null +++ b/.github/scripts/classify-changes.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Classifies changed files into categories for CI job filtering. +# Inputs (env vars): EVENT_NAME, BASE_REF +# Outputs: writes category=true/false pairs to $GITHUB_OUTPUT and a summary table to $GITHUB_STEP_SUMMARY + +if [[ "$EVENT_NAME" != "pull_request" ]]; then + echo "Non-PR event ($EVENT_NAME), setting all categories to true" + for cat in code docs ci format_config benchmark gifs mill_wrapper; do + echo "$cat=true" >> "$GITHUB_OUTPUT" + done + exit 0 +fi + +CHANGED_FILES=$(git diff --name-only "origin/$BASE_REF...HEAD" || echo "DIFF_FAILED") +if [[ "$CHANGED_FILES" == "DIFF_FAILED" ]]; then + echo "::warning::Failed to compute diff, running all jobs" + for cat in code docs ci format_config benchmark gifs mill_wrapper; do + echo "$cat=true" >> "$GITHUB_OUTPUT" + done + exit 0 +fi + +CODE=false; DOCS=false; CI=false; FORMAT_CONFIG=false; BENCHMARK=false; GIFS=false; MILL_WRAPPER=false + +while IFS= read -r file; do + case "$file" in + modules/*|build.mill|project/*) CODE=true ;; + website/*) DOCS=true ;; + .github/*) CI=true ;; + .scalafmt.conf|.scalafix.conf) FORMAT_CONFIG=true ;; + gcbenchmark/*) BENCHMARK=true ;; + gifs/*) GIFS=true ;; + mill|mill.bat) MILL_WRAPPER=true ;; + esac +done <<< "$CHANGED_FILES" + +echo "Change categories:" +echo " code=$CODE" +echo " docs=$DOCS" +echo " ci=$CI" +echo " format_config=$FORMAT_CONFIG" +echo " benchmark=$BENCHMARK" +echo " gifs=$GIFS" +echo " mill_wrapper=$MILL_WRAPPER" + +echo "code=$CODE" >> "$GITHUB_OUTPUT" +echo "docs=$DOCS" >> "$GITHUB_OUTPUT" +echo "ci=$CI" >> "$GITHUB_OUTPUT" +echo "format_config=$FORMAT_CONFIG" >> "$GITHUB_OUTPUT" +echo "benchmark=$BENCHMARK" >> "$GITHUB_OUTPUT" +echo "gifs=$GIFS" >> "$GITHUB_OUTPUT" +echo "mill_wrapper=$MILL_WRAPPER" >> "$GITHUB_OUTPUT" + +echo "## Change categories" >> "$GITHUB_STEP_SUMMARY" +echo "| Category | Changed |" >> "$GITHUB_STEP_SUMMARY" +echo "|----------|---------|" >> "$GITHUB_STEP_SUMMARY" +for cat in code docs ci format_config benchmark gifs mill_wrapper; do + val=$(eval echo \$$( echo $cat | tr 'a-z' 'A-Z')) + echo "| $cat | $val |" >> "$GITHUB_STEP_SUMMARY" +done diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54100be807..f6e26a5489 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,265 +13,395 @@ concurrency: cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: + changes: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + code: ${{ steps.classify.outputs.code }} + docs: ${{ steps.classify.outputs.docs }} + ci: ${{ steps.classify.outputs.ci }} + format_config: ${{ steps.classify.outputs.format_config }} + benchmark: ${{ steps.classify.outputs.benchmark }} + gifs: ${{ steps.classify.outputs.gifs }} + mill_wrapper: ${{ steps.classify.outputs.mill_wrapper }} + test_all: ${{ steps.overrides.outputs.test_all }} + test_native: ${{ steps.overrides.outputs.test_native }} + test_integration: ${{ steps.overrides.outputs.test_integration }} + test_docs: ${{ steps.overrides.outputs.test_docs }} + test_format: ${{ steps.overrides.outputs.test_format }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Classify changes + id: classify + env: + EVENT_NAME: ${{ github.event_name }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: .github/scripts/classify-changes.sh + - name: Check override keywords + id: overrides + env: + EVENT_NAME: ${{ github.event_name }} + PR_BODY: ${{ github.event.pull_request.body }} + run: .github/scripts/check-override-keywords.sh + unit-tests: + needs: [changes] timeout-minutes: 120 runs-on: ubuntu-24.04 + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.mill_wrapper == 'true' || needs.changes.outputs.test_all == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping unit tests -- changes do not affect compiled code, CI, or mill wrapper." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" - name: Copy launcher run: ./mill -i copyJvmLauncher --directory artifacts/ - if: runner.os == 'Linux' + if: runner.os == 'Linux' && env.SHOULD_RUN == 'true' - name: Copy bootstrapped launcher run: ./mill -i copyJvmBootstrappedLauncher --directory artifacts/ - if: runner.os == 'Linux' + if: runner.os == 'Linux' && env.SHOULD_RUN == 'true' - uses: actions/upload-artifact@v7 - if: runner.os == 'Linux' + if: runner.os == 'Linux' && env.SHOULD_RUN == 'true' with: name: jvm-launchers path: artifacts/ if-no-files-found: error retention-days: 2 - name: Cross compile everything + if: env.SHOULD_RUN == 'true' run: ./mill -i '__[_].compile' - name: Build macros negative compilation tests + if: env.SHOULD_RUN == 'true' run: ./mill -i build-macros[_].test.testNegativeCompilation - name: Unit tests + if: env.SHOULD_RUN == 'true' run: ./mill -i unitTests - name: Convert Mill test reports to JUnit XML format - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' run: .github/scripts/generate-junit-reports.sc unit-tests 'Scala CLI Unit Tests' test-report.xml out/ - name: Upload test report uses: actions/upload-artifact@v7 - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' with: name: test-results-unit-tests path: test-report.xml test-fish-shell: + needs: [changes] timeout-minutes: 120 runs-on: ubuntu-24.04 + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.mill_wrapper == 'true' || needs.changes.outputs.test_all == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping fish shell test -- changes do not affect compiled code, CI, or mill wrapper." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" - name: Install fish + if: env.SHOULD_RUN == 'true' run: | sudo apt-add-repository ppa:fish-shell/release-3 sudo apt update sudo apt install fish - name: Test mill script in fish shell + if: env.SHOULD_RUN == 'true' run: | fish -c './mill __.compile' jvm-bootstrapped-tests-default: + needs: [changes] timeout-minutes: 150 runs-on: ubuntu-24.04 + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.test_all == 'true' || needs.changes.outputs.test_integration == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping JVM bootstrapped integration tests -- changes do not affect compiled code or CI." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" - name: JVM integration tests + if: env.SHOULD_RUN == 'true' run: ./mill -i integration.test.jvmBootstrapped env: SCALA_CLI_IT_GROUP: 1 - name: Convert Mill test reports to JUnit XML format - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' run: .github/scripts/generate-junit-reports.sc jvm-bootstrapped-tests-default 'Scala CLI JVM Bootstrapped Tests' test-report.xml out/ - name: Upload test report uses: actions/upload-artifact@v7 - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' with: name: test-results-jvm-bootstrapped-tests-default path: test-report.xml jvm-tests-default: + needs: [changes] timeout-minutes: 150 runs-on: ubuntu-24.04 + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.test_all == 'true' || needs.changes.outputs.test_integration == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping JVM integration tests (default) -- changes do not affect compiled code or CI." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" - name: JVM integration tests + if: env.SHOULD_RUN == 'true' run: ./mill -i integration.test.jvm env: SCALA_CLI_IT_GROUP: 1 - name: Convert Mill test reports to JUnit XML format - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' run: .github/scripts/generate-junit-reports.sc jvm-tests-default 'Scala CLI JVM Tests (default)' test-report.xml out/ - name: Upload test report uses: actions/upload-artifact@v7 - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' with: name: test-results-jvm-tests-default path: test-report.xml jvm-tests-scala-2-13: + needs: [changes] timeout-minutes: 150 runs-on: ubuntu-24.04 + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.test_all == 'true' || needs.changes.outputs.test_integration == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping JVM integration tests (Scala 2.13) -- changes do not affect compiled code or CI." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" - name: JVM integration tests + if: env.SHOULD_RUN == 'true' run: ./mill -i integration.test.jvm env: SCALA_CLI_IT_GROUP: 2 - name: Convert Mill test reports to JUnit XML format - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' run: .github/scripts/generate-junit-reports.sc jvm-tests-scala-2-13 'Scala CLI JVM Tests (Scala 2.13)' test-report.xml out/ - name: Upload test report uses: actions/upload-artifact@v7 - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' with: name: test-results-jvm-tests-scala-2-13 path: test-report.xml jvm-tests-scala-2-12: + needs: [changes] timeout-minutes: 150 runs-on: ubuntu-24.04 + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.test_all == 'true' || needs.changes.outputs.test_integration == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping JVM integration tests (Scala 2.12) -- changes do not affect compiled code or CI." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" - name: JVM integration tests + if: env.SHOULD_RUN == 'true' run: ./mill -i integration.test.jvm env: SCALA_CLI_IT_GROUP: 3 - name: Convert Mill test reports to JUnit XML format - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' run: .github/scripts/generate-junit-reports.sc jvm-tests-scala-2-12 'Scala CLI JVM Tests (Scala 2.12)' test-report.xml out/ - name: Upload test report uses: actions/upload-artifact@v7 - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' with: name: test-results-jvm-tests-scala-2-12 path: test-report.xml jvm-tests-lts: + needs: [changes] timeout-minutes: 150 runs-on: ubuntu-24.04 + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.test_all == 'true' || needs.changes.outputs.test_integration == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping JVM integration tests (Scala 3 LTS) -- changes do not affect compiled code or CI." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" - name: JVM integration tests + if: env.SHOULD_RUN == 'true' run: ./mill -i integration.test.jvm env: SCALA_CLI_IT_GROUP: 4 - name: Convert Mill test reports to JUnit XML format - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' run: .github/scripts/generate-junit-reports.sc jvm-tests-lts 'Scala CLI JVM Tests (Scala 3 LTS)' test-report.xml out/ - name: Upload test report uses: actions/upload-artifact@v7 - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' with: name: test-results-jvm-tests-lts path: test-report.xml jvm-tests-rc: + needs: [changes] timeout-minutes: 150 runs-on: ubuntu-24.04 + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.test_all == 'true' || needs.changes.outputs.test_integration == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping JVM integration tests (RC) -- changes do not affect compiled code or CI." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" - name: JVM integration tests + if: env.SHOULD_RUN == 'true' run: ./mill -i integration.test.jvm env: SCALA_CLI_IT_GROUP: 5 - name: Convert Mill test reports to JUnit XML format - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' run: .github/scripts/generate-junit-reports.sc jvm-tests-rc 'Scala CLI JVM Tests (5)' test-report.xml out/ - name: Upload test report uses: actions/upload-artifact@v7 - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' with: name: test-results-jvm-tests-rc path: test-report.xml generate-linux-launcher: + needs: [changes] timeout-minutes: 120 runs-on: ubuntu-24.04 + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.test_all == 'true' || needs.changes.outputs.test_native == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping Linux native launcher generation -- changes do not affect compiled code or CI." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" - name: Generate native launcher + if: env.SHOULD_RUN == 'true' run: .github/scripts/generate-native-image.sh - run: ./mill -i ci.setShouldPublish + if: env.SHOULD_RUN == 'true' - name: Build OS packages - if: env.SHOULD_PUBLISH == 'true' + if: env.SHOULD_PUBLISH == 'true' && env.SHOULD_RUN == 'true' run: .github/scripts/generate-os-packages.sh - name: Copy artifacts + if: env.SHOULD_RUN == 'true' run: ./mill -i copyDefaultLauncher --directory artifacts/ - name: Verify native launcher CPU compatibility + if: env.SHOULD_RUN == 'true' run: .github/scripts/verify_old_cpus.sh artifacts/scala-cli-x86_64-pc-linux.gz - uses: actions/upload-artifact@v7 + if: env.SHOULD_RUN == 'true' with: name: linux-launchers path: artifacts/ @@ -279,25 +409,35 @@ jobs: retention-days: 2 native-linux-tests-default: - needs: generate-linux-launcher + needs: [changes, generate-linux-launcher] timeout-minutes: 150 runs-on: ubuntu-24.04 + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.test_all == 'true' || needs.changes.outputs.test_native == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping native Linux integration tests (default) -- changes do not affect compiled code or CI." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" - uses: actions/download-artifact@v8 + if: env.SHOULD_RUN == 'true' with: name: linux-launchers path: artifacts/ - name: Native integration tests + if: env.SHOULD_RUN == 'true' run: ./mill -i nativeIntegrationTests env: UPDATE_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -305,35 +445,45 @@ jobs: SCALA_CLI_IT_GROUP: 1 SCALA_CLI_SODIUM_JNI_ALLOW: false - name: Convert Mill test reports to JUnit XML format - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' run: .github/scripts/generate-junit-reports.sc linux-tests-default 'Scala CLI Linux Tests (default)' test-report.xml out/ - name: Upload test report uses: actions/upload-artifact@v7 - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' with: name: test-results-linux-tests-default path: test-report.xml native-linux-tests-scala-2-13: - needs: generate-linux-launcher + needs: [changes, generate-linux-launcher] timeout-minutes: 150 runs-on: ubuntu-24.04 + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.test_all == 'true' || needs.changes.outputs.test_native == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping native Linux integration tests (Scala 2.13) -- changes do not affect compiled code or CI." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" - uses: actions/download-artifact@v8 + if: env.SHOULD_RUN == 'true' with: name: linux-launchers path: artifacts/ - name: Native integration tests + if: env.SHOULD_RUN == 'true' run: ./mill -i nativeIntegrationTests env: UPDATE_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -341,35 +491,45 @@ jobs: SCALA_CLI_IT_GROUP: 2 SCALA_CLI_SODIUM_JNI_ALLOW: false - name: Convert Mill test reports to JUnit XML format - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' run: .github/scripts/generate-junit-reports.sc linux-tests-scala-2-13 'Scala CLI Linux Tests (Scala 2.13)' test-report.xml out/ - name: Upload test report uses: actions/upload-artifact@v7 - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' with: name: test-results-linux-tests-scala-2-13 path: test-report.xml native-linux-tests-scala-2-12: - needs: generate-linux-launcher + needs: [changes, generate-linux-launcher] timeout-minutes: 150 runs-on: ubuntu-24.04 + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.test_all == 'true' || needs.changes.outputs.test_native == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping native Linux integration tests (Scala 2.12) -- changes do not affect compiled code or CI." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" - uses: actions/download-artifact@v8 + if: env.SHOULD_RUN == 'true' with: name: linux-launchers path: artifacts/ - name: Native integration tests + if: env.SHOULD_RUN == 'true' run: ./mill -i nativeIntegrationTests env: UPDATE_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -377,35 +537,45 @@ jobs: SCALA_CLI_IT_GROUP: 3 SCALA_CLI_SODIUM_JNI_ALLOW: false - name: Convert Mill test reports to JUnit XML format - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' run: .github/scripts/generate-junit-reports.sc linux-tests-scala-2-12 'Scala CLI Linux Tests (Scala 2.12)' test-report.xml out/ - name: Upload test report uses: actions/upload-artifact@v7 - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' with: name: test-results-linux-tests-scala-2-12 path: test-report.xml native-linux-tests-lts: - needs: generate-linux-launcher + needs: [changes, generate-linux-launcher] timeout-minutes: 150 runs-on: ubuntu-24.04 + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.test_all == 'true' || needs.changes.outputs.test_native == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping native Linux integration tests (Scala 3 LTS) -- changes do not affect compiled code or CI." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" - uses: actions/download-artifact@v8 + if: env.SHOULD_RUN == 'true' with: name: linux-launchers path: artifacts/ - name: Native integration tests + if: env.SHOULD_RUN == 'true' run: ./mill -i nativeIntegrationTests env: UPDATE_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -413,35 +583,45 @@ jobs: SCALA_CLI_IT_GROUP: 4 SCALA_CLI_SODIUM_JNI_ALLOW: false - name: Convert Mill test reports to JUnit XML format - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' run: .github/scripts/generate-junit-reports.sc linux-tests-lts 'Scala CLI Linux Tests (Scala 3 LTS)' test-report.xml out/ - name: Upload test report uses: actions/upload-artifact@v7 - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' with: name: test-results-linux-tests-lts path: test-report.xml native-linux-tests-rc: - needs: generate-linux-launcher + needs: [changes, generate-linux-launcher] timeout-minutes: 150 runs-on: ubuntu-24.04 + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.test_all == 'true' || needs.changes.outputs.test_native == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping native Linux integration tests (RC) -- changes do not affect compiled code or CI." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" - uses: actions/download-artifact@v8 + if: env.SHOULD_RUN == 'true' with: name: linux-launchers path: artifacts/ - name: Native integration tests + if: env.SHOULD_RUN == 'true' run: ./mill -i nativeIntegrationTests env: UPDATE_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -449,42 +629,56 @@ jobs: SCALA_CLI_IT_GROUP: 5 SCALA_CLI_SODIUM_JNI_ALLOW: false - name: Convert Mill test reports to JUnit XML format - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' run: .github/scripts/generate-junit-reports.sc linux-tests-rc 'Scala CLI Linux Tests (5)' test-report.xml out/ - name: Upload test report uses: actions/upload-artifact@v7 - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' with: name: test-results-linux-tests-rc path: test-report.xml generate-linux-arm64-native-launcher: + needs: [changes] timeout-minutes: 120 runs-on: ubuntu-24.04-arm + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.test_all == 'true' || needs.changes.outputs.test_native == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping Linux ARM64 native launcher generation -- changes do not affect compiled code or CI." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" - name: Install build dependencies + if: env.SHOULD_RUN == 'true' run: | sudo apt-get update -q -y sudo apt-get install -q -y build-essential libz-dev zlib1g-dev python3-pip - name: Generate native launcher + if: env.SHOULD_RUN == 'true' run: .github/scripts/generate-native-image.sh - run: ./mill -i ci.setShouldPublish + if: env.SHOULD_RUN == 'true' - name: Build OS packages - if: env.SHOULD_PUBLISH == 'true' + if: env.SHOULD_PUBLISH == 'true' && env.SHOULD_RUN == 'true' run: .github/scripts/generate-os-packages.sh - name: Copy artifacts + if: env.SHOULD_RUN == 'true' run: ./mill -i copyDefaultLauncher --directory artifacts/ - uses: actions/upload-artifact@v7 + if: env.SHOULD_RUN == 'true' with: name: linux-aarch64-launchers path: artifacts/ @@ -492,25 +686,35 @@ jobs: retention-days: 2 native-linux-arm64-tests-default: - needs: generate-linux-arm64-native-launcher + needs: [changes, generate-linux-arm64-native-launcher] timeout-minutes: 150 runs-on: ubuntu-24.04-arm + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.test_all == 'true' || needs.changes.outputs.test_native == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping native Linux ARM64 integration tests (default) -- changes do not affect compiled code or CI." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" - uses: actions/download-artifact@v8 + if: env.SHOULD_RUN == 'true' with: name: linux-aarch64-launchers path: artifacts/ - name: Native integration tests + if: env.SHOULD_RUN == 'true' run: ./mill -i nativeIntegrationTests env: UPDATE_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -518,35 +722,45 @@ jobs: SCALA_CLI_IT_GROUP: 1 SCALA_CLI_SODIUM_JNI_ALLOW: false - name: Convert Mill test reports to JUnit XML format - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' run: .github/scripts/generate-junit-reports.sc linux-tests-default 'Scala CLI Linux ARM 64 Tests (default)' test-report.xml out/ - name: Upload test report uses: actions/upload-artifact@v7 - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' with: name: test-results-linux-arm64-tests-default path: test-report.xml native-linux-arm64-tests-rc: - needs: generate-linux-arm64-native-launcher + needs: [changes, generate-linux-arm64-native-launcher] timeout-minutes: 150 runs-on: ubuntu-24.04-arm + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.test_all == 'true' || needs.changes.outputs.test_native == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping native Linux ARM64 integration tests (RC) -- changes do not affect compiled code or CI." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" - uses: actions/download-artifact@v8 + if: env.SHOULD_RUN == 'true' with: name: linux-aarch64-launchers path: artifacts/ - name: Native integration tests + if: env.SHOULD_RUN == 'true' run: ./mill -i nativeIntegrationTests env: UPDATE_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -554,40 +768,54 @@ jobs: SCALA_CLI_IT_GROUP: 5 SCALA_CLI_SODIUM_JNI_ALLOW: false - name: Convert Mill test reports to JUnit XML format - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' run: .github/scripts/generate-junit-reports.sc linux-tests-rc 'Scala CLI Linux ARM64 Tests (5)' test-report.xml out/ - name: Upload test report uses: actions/upload-artifact@v7 - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' with: name: test-results-linux-arm64-tests-rc path: test-report.xml generate-macos-launcher: + needs: [changes] timeout-minutes: 120 runs-on: "macOS-15-intel" + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.test_all == 'true' || needs.changes.outputs.test_native == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping macOS native launcher generation -- changes do not affect compiled code or CI." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" - name: Ensure it's not running on aarch64 + if: env.SHOULD_RUN == 'true' run: scala-cli -e 'assert(System.getProperty("os.arch") != "aarch64")' - name: Generate native launcher + if: env.SHOULD_RUN == 'true' run: .github/scripts/generate-native-image.sh - run: ./mill -i ci.setShouldPublish + if: env.SHOULD_RUN == 'true' - name: Build OS packages - if: env.SHOULD_PUBLISH == 'true' + if: env.SHOULD_PUBLISH == 'true' && env.SHOULD_RUN == 'true' run: .github/scripts/generate-os-packages.sh - name: Copy artifacts + if: env.SHOULD_RUN == 'true' run: ./mill -i copyDefaultLauncher --directory artifacts/ - uses: actions/upload-artifact@v7 + if: env.SHOULD_RUN == 'true' with: name: macos-launchers path: artifacts/ @@ -595,27 +823,38 @@ jobs: retention-days: 2 native-macos-tests-default: - needs: generate-macos-launcher + needs: [changes, generate-macos-launcher] timeout-minutes: 150 runs-on: "macOS-15-intel" + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.test_all == 'true' || needs.changes.outputs.test_native == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping native macOS integration tests (default) -- changes do not affect compiled code or CI." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" - name: Ensure it's not running on aarch64 + if: env.SHOULD_RUN == 'true' run: scala-cli -e 'assert(System.getProperty("os.arch") != "aarch64")' - uses: actions/download-artifact@v8 + if: env.SHOULD_RUN == 'true' with: name: macos-launchers path: artifacts/ - name: Native integration tests + if: env.SHOULD_RUN == 'true' run: ./mill -i nativeIntegrationTests env: UPDATE_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -623,37 +862,48 @@ jobs: SCALA_CLI_IT_GROUP: 1 SCALA_CLI_SODIUM_JNI_ALLOW: false - name: Convert Mill test reports to JUnit XML format - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' run: .github/scripts/generate-junit-reports.sc macos-tests-default 'Scala CLI MacOS Tests (default)' test-report.xml out/ - name: Upload test report uses: actions/upload-artifact@v7 - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' with: name: test-results-macos-tests-default path: test-report.xml native-macos-tests-rc: - needs: generate-macos-launcher + needs: [changes, generate-macos-launcher] timeout-minutes: 150 runs-on: "macOS-15-intel" + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.test_all == 'true' || needs.changes.outputs.test_native == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping native macOS integration tests (RC) -- changes do not affect compiled code or CI." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" - name: Ensure it's not running on aarch64 + if: env.SHOULD_RUN == 'true' run: scala-cli -e 'assert(System.getProperty("os.arch") != "aarch64")' - uses: actions/download-artifact@v8 + if: env.SHOULD_RUN == 'true' with: name: macos-launchers path: artifacts/ - name: Native integration tests + if: env.SHOULD_RUN == 'true' run: ./mill -i nativeIntegrationTests env: UPDATE_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -661,40 +911,54 @@ jobs: SCALA_CLI_IT_GROUP: 5 SCALA_CLI_SODIUM_JNI_ALLOW: false - name: Convert Mill test reports to JUnit XML format - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' run: .github/scripts/generate-junit-reports.sc macos-tests-rc 'Scala CLI MacOS Tests (5)' test-report.xml out/ - name: Upload test report uses: actions/upload-artifact@v7 - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' with: name: test-results-macos-tests-rc path: test-report.xml generate-macos-arm64-launcher: + needs: [changes] timeout-minutes: 120 runs-on: "macOS-15" + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.test_all == 'true' || needs.changes.outputs.test_native == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping macOS ARM64 native launcher generation -- changes do not affect compiled code or CI." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "https://github.com/graalvm/graalvm-ce-builds/releases/download/vm-22.2.0/graalvm-ce-java17-darwin-aarch64-22.2.0.tar.gz" - name: Ensure it's running on aarch64 + if: env.SHOULD_RUN == 'true' run: scala-cli -e 'assert(System.getProperty("os.arch") == "aarch64")' - name: Generate native launcher + if: env.SHOULD_RUN == 'true' run: .github/scripts/generate-native-image.sh - run: ./mill -i ci.setShouldPublish + if: env.SHOULD_RUN == 'true' - name: Build OS packages - if: env.SHOULD_PUBLISH == 'true' + if: env.SHOULD_PUBLISH == 'true' && env.SHOULD_RUN == 'true' run: .github/scripts/generate-os-packages.sh - name: Copy artifacts + if: env.SHOULD_RUN == 'true' run: ./mill -i copyDefaultLauncher --directory artifacts/ - uses: actions/upload-artifact@v7 + if: env.SHOULD_RUN == 'true' with: name: macos-arm64-launchers path: artifacts/ @@ -702,27 +966,38 @@ jobs: retention-days: 2 native-macos-arm64-tests-default: - needs: generate-macos-arm64-launcher + needs: [changes, generate-macos-arm64-launcher] timeout-minutes: 150 runs-on: "macOS-15" + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.test_all == 'true' || needs.changes.outputs.test_native == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping native macOS ARM64 integration tests (default) -- changes do not affect compiled code or CI." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "https://github.com/graalvm/graalvm-ce-builds/releases/download/vm-22.2.0/graalvm-ce-java17-darwin-aarch64-22.2.0.tar.gz" - name: Ensure it's running on aarch64 + if: env.SHOULD_RUN == 'true' run: scala-cli -e 'assert(System.getProperty("os.arch") == "aarch64")' - uses: actions/download-artifact@v8 + if: env.SHOULD_RUN == 'true' with: name: macos-arm64-launchers path: artifacts/ - name: Native integration tests + if: env.SHOULD_RUN == 'true' run: ./mill -i nativeIntegrationTests env: UPDATE_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -730,37 +1005,48 @@ jobs: SCALA_CLI_IT_GROUP: 1 SCALA_CLI_SODIUM_JNI_ALLOW: false - name: Convert Mill test reports to JUnit XML format - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' run: .github/scripts/generate-junit-reports.sc macos-arm64-tests-default 'Scala CLI MacOS ARM64 Tests (default)' test-report.xml out/ - name: Upload test report uses: actions/upload-artifact@v7 - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' with: name: test-results-macos-arm64-tests-default path: test-report.xml native-macos-arm64-tests-lts: - needs: generate-macos-arm64-launcher + needs: [changes, generate-macos-arm64-launcher] timeout-minutes: 150 runs-on: "macOS-15" + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.test_all == 'true' || needs.changes.outputs.test_native == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping native macOS ARM64 integration tests (Scala 3 LTS) -- changes do not affect compiled code or CI." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "https://github.com/graalvm/graalvm-ce-builds/releases/download/vm-22.2.0/graalvm-ce-java17-darwin-aarch64-22.2.0.tar.gz" - name: Ensure it's running on aarch64 + if: env.SHOULD_RUN == 'true' run: scala-cli -e 'assert(System.getProperty("os.arch") == "aarch64")' - uses: actions/download-artifact@v8 + if: env.SHOULD_RUN == 'true' with: name: macos-arm64-launchers path: artifacts/ - name: Native integration tests + if: env.SHOULD_RUN == 'true' run: ./mill -i nativeIntegrationTests env: UPDATE_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -768,37 +1054,48 @@ jobs: SCALA_CLI_IT_GROUP: 4 SCALA_CLI_SODIUM_JNI_ALLOW: false - name: Convert Mill test reports to JUnit XML format - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' run: .github/scripts/generate-junit-reports.sc macos-arm64-tests-lts 'Scala CLI MacOS ARM64 Tests (Scala 3 LTS)' test-report.xml out/ - name: Upload test report uses: actions/upload-artifact@v7 - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' with: name: test-results-macos-arm64-tests-lts path: test-report.xml native-macos-arm64-tests-rc: - needs: generate-macos-arm64-launcher + needs: [changes, generate-macos-arm64-launcher] timeout-minutes: 150 runs-on: "macOS-15" + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.test_all == 'true' || needs.changes.outputs.test_native == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping native macOS ARM64 integration tests (RC) -- changes do not affect compiled code or CI." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "https://github.com/graalvm/graalvm-ce-builds/releases/download/vm-22.2.0/graalvm-ce-java17-darwin-aarch64-22.2.0.tar.gz" - name: Ensure it's running on aarch64 + if: env.SHOULD_RUN == 'true' run: scala-cli -e 'assert(System.getProperty("os.arch") == "aarch64")' - uses: actions/download-artifact@v8 + if: env.SHOULD_RUN == 'true' with: name: macos-arm64-launchers path: artifacts/ - name: Native integration tests + if: env.SHOULD_RUN == 'true' run: ./mill -i nativeIntegrationTests env: UPDATE_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -806,47 +1103,62 @@ jobs: SCALA_CLI_IT_GROUP: 5 SCALA_CLI_SODIUM_JNI_ALLOW: false - name: Convert Mill test reports to JUnit XML format - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' run: .github/scripts/generate-junit-reports.sc macos-arm64-tests-rc 'Scala CLI MacOS ARM64 Tests (5)' test-report.xml out/ - name: Upload test report uses: actions/upload-artifact@v7 - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' with: name: test-results-macos-arm64-tests-rc path: test-report.xml generate-windows-launcher: + needs: [changes] timeout-minutes: 120 runs-on: "windows-2025" + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.test_all == 'true' || needs.changes.outputs.test_native == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping Windows native launcher generation -- changes do not affect compiled code or CI." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - name: Import custom registry and verify + if: env.SHOULD_RUN == 'true' uses: ./.github/actions/windows-reg-import with: reg-file: .github/ci/windows/custom.reg - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" - name: Get latest coursier launcher + if: env.SHOULD_RUN == 'true' run: .github/scripts/get-latest-cs.sh shell: bash - name: Generate native launcher + if: env.SHOULD_RUN == 'true' run: .github/scripts/generate-native-image.sh shell: bash - run: ./mill -i ci.setShouldPublish + if: env.SHOULD_RUN == 'true' - name: Build OS packages - if: env.SHOULD_PUBLISH == 'true' + if: env.SHOULD_PUBLISH == 'true' && env.SHOULD_RUN == 'true' run: .github/scripts/generate-os-packages.sh shell: bash - name: Copy artifacts + if: env.SHOULD_RUN == 'true' run: ./mill -i copyDefaultLauncher --directory artifacts/ - uses: actions/upload-artifact@v7 + if: env.SHOULD_RUN == 'true' with: name: windows-launchers path: artifacts/ @@ -854,36 +1166,49 @@ jobs: retention-days: 2 native-windows-tests-default: - needs: generate-windows-launcher + needs: [changes, generate-windows-launcher] timeout-minutes: 150 runs-on: "windows-2025" + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.test_all == 'true' || needs.changes.outputs.test_native == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping native Windows integration tests (default) -- changes do not affect compiled code or CI." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - name: Import custom registry and verify + if: env.SHOULD_RUN == 'true' uses: ./.github/actions/windows-reg-import with: reg-file: .github/ci/windows/custom.reg - name: Set up Python + if: env.SHOULD_RUN == 'true' uses: actions/setup-python@v6 with: python-version: "3.10" - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" - name: Get latest coursier launcher + if: env.SHOULD_RUN == 'true' run: .github/scripts/get-latest-cs.sh shell: bash - uses: actions/download-artifact@v8 + if: env.SHOULD_RUN == 'true' with: name: windows-launchers path: artifacts/ - name: Native integration tests + if: env.SHOULD_RUN == 'true' run: ./mill -i nativeIntegrationTests env: COURSIER_JNI: force @@ -892,46 +1217,59 @@ jobs: SCALA_CLI_IT_GROUP: 1 SCALA_CLI_SODIUM_JNI_ALLOW: false - name: Convert Mill test reports to JUnit XML format - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' run: scala-cli shebang .github/scripts/generate-junit-reports.sc windows-tests-default 'Scala CLI Windows Tests (default)' test-report.xml out/ - name: Upload test report uses: actions/upload-artifact@v7 - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' with: name: test-results-windows-tests-default path: test-report.xml native-windows-tests-lts: - needs: generate-windows-launcher + needs: [changes, generate-windows-launcher] timeout-minutes: 150 runs-on: "windows-2025" + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.test_all == 'true' || needs.changes.outputs.test_native == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping native Windows integration tests (Scala 3 LTS) -- changes do not affect compiled code or CI." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - name: Import custom registry and verify + if: env.SHOULD_RUN == 'true' uses: ./.github/actions/windows-reg-import with: reg-file: .github/ci/windows/custom.reg - name: Set up Python + if: env.SHOULD_RUN == 'true' uses: actions/setup-python@v6 with: python-version: "3.10" - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" - name: Get latest coursier launcher + if: env.SHOULD_RUN == 'true' run: .github/scripts/get-latest-cs.sh shell: bash - uses: actions/download-artifact@v8 + if: env.SHOULD_RUN == 'true' with: name: windows-launchers path: artifacts/ - name: Native integration tests + if: env.SHOULD_RUN == 'true' run: ./mill -i nativeIntegrationTests env: COURSIER_JNI: force @@ -940,46 +1278,59 @@ jobs: SCALA_CLI_IT_GROUP: 4 SCALA_CLI_SODIUM_JNI_ALLOW: false - name: Convert Mill test reports to JUnit XML format - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' run: scala-cli shebang .github/scripts/generate-junit-reports.sc windows-tests-lts 'Scala CLI Windows Tests (Scala 3 LTS)' test-report.xml out/ - name: Upload test report uses: actions/upload-artifact@v7 - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' with: name: test-results-windows-tests-lts path: test-report.xml native-windows-tests-rc: - needs: generate-windows-launcher + needs: [changes, generate-windows-launcher] timeout-minutes: 150 runs-on: "windows-2025" + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.test_all == 'true' || needs.changes.outputs.test_native == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping native Windows integration tests (RC) -- changes do not affect compiled code or CI." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - name: Import custom registry and verify + if: env.SHOULD_RUN == 'true' uses: ./.github/actions/windows-reg-import with: reg-file: .github/ci/windows/custom.reg - name: Set up Python + if: env.SHOULD_RUN == 'true' uses: actions/setup-python@v6 with: python-version: "3.10" - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" - name: Get latest coursier launcher + if: env.SHOULD_RUN == 'true' run: .github/scripts/get-latest-cs.sh shell: bash - uses: actions/download-artifact@v8 + if: env.SHOULD_RUN == 'true' with: name: windows-launchers path: artifacts/ - name: Native integration tests + if: env.SHOULD_RUN == 'true' run: ./mill -i nativeIntegrationTests env: COURSIER_JNI: force @@ -988,35 +1339,47 @@ jobs: SCALA_CLI_IT_GROUP: 5 SCALA_CLI_SODIUM_JNI_ALLOW: false - name: Convert Mill test reports to JUnit XML format - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' run: scala-cli shebang .github/scripts/generate-junit-reports.sc windows-tests-rc 'Scala CLI Windows Tests (5)' test-report.xml out/ - name: Upload test report uses: actions/upload-artifact@v7 - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' with: name: test-results-windows-tests-rc path: test-report.xml generate-mostly-static-launcher: + needs: [changes] timeout-minutes: 120 runs-on: ubuntu-24.04 + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.test_all == 'true' || needs.changes.outputs.test_native == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping mostly-static native launcher generation -- changes do not affect compiled code or CI." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" - name: Generate native launcher + if: env.SHOULD_RUN == 'true' run: .github/scripts/generate-native-image.sh mostly-static shell: bash - name: Copy artifacts + if: env.SHOULD_RUN == 'true' run: ./mill -i copyMostlyStaticLauncher --directory artifacts/ - uses: actions/upload-artifact@v7 + if: env.SHOULD_RUN == 'true' with: name: mostly-static-launchers path: artifacts/ @@ -1024,27 +1387,38 @@ jobs: retention-days: 2 native-mostly-static-tests-default: - needs: generate-mostly-static-launcher + needs: [changes, generate-mostly-static-launcher] timeout-minutes: 150 runs-on: ubuntu-24.04 + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.test_all == 'true' || needs.changes.outputs.test_native == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping native mostly-static integration tests (default) -- changes do not affect compiled code or CI." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" - uses: actions/download-artifact@v8 + if: env.SHOULD_RUN == 'true' with: name: mostly-static-launchers path: artifacts/ - name: Build slim docker image + if: env.SHOULD_RUN == 'true' run: .github/scripts/generate-slim-docker-image.sh - name: Native integration tests + if: env.SHOULD_RUN == 'true' run: ./mill -i integration.test.nativeMostlyStatic env: UPDATE_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -1052,47 +1426,57 @@ jobs: SCALA_CLI_IT_GROUP: 1 SCALA_CLI_SODIUM_JNI_ALLOW: false - name: Docker integration tests - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' run: ./mill integration.docker-slim.test - name: Convert Mill test reports to JUnit XML format - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' run: .github/scripts/generate-junit-reports.sc native-mostly-static-tests-default 'Scala CLI Native Mostly Static Tests (default)' test-report.xml out/ - name: Upload test report uses: actions/upload-artifact@v7 - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' with: name: test-results-native-mostly-static-tests-default path: test-report.xml - name: Login to GitHub Container Registry - if: startsWith(github.ref, 'refs/tags/v') + if: startsWith(github.ref, 'refs/tags/v') && env.SHOULD_RUN == 'true' uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Push slim scala-cli image to github container registry - if: startsWith(github.ref, 'refs/tags/v') + if: startsWith(github.ref, 'refs/tags/v') && env.SHOULD_RUN == 'true' run: .github/scripts/publish-slim-docker-images.sh native-mostly-static-tests-rc: - needs: generate-mostly-static-launcher + needs: [changes, generate-mostly-static-launcher] timeout-minutes: 150 runs-on: ubuntu-24.04 + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.test_all == 'true' || needs.changes.outputs.test_native == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping native mostly-static integration tests (RC) -- changes do not affect compiled code or CI." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" - uses: actions/download-artifact@v8 + if: env.SHOULD_RUN == 'true' with: name: mostly-static-launchers path: artifacts/ - name: Native integration tests + if: env.SHOULD_RUN == 'true' run: ./mill -i integration.test.nativeMostlyStatic env: UPDATE_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -1100,36 +1484,48 @@ jobs: SCALA_CLI_IT_GROUP: 5 SCALA_CLI_SODIUM_JNI_ALLOW: false - name: Convert Mill test reports to JUnit XML format - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' run: .github/scripts/generate-junit-reports.sc native-mostly-static-tests-rc 'Scala CLI Native Mostly Static Tests (5)' test-report.xml out/ - name: Upload test report uses: actions/upload-artifact@v7 - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' with: name: test-results-native-mostly-static-tests-rc path: test-report.xml generate-static-launcher: + needs: [changes] timeout-minutes: 120 runs-on: ubuntu-24.04 + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.test_all == 'true' || needs.changes.outputs.test_native == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping static native launcher generation -- changes do not affect compiled code or CI." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" - name: Generate native launcher + if: env.SHOULD_RUN == 'true' run: .github/scripts/generate-native-image.sh static shell: bash - name: Copy artifacts + if: env.SHOULD_RUN == 'true' run: ./mill -i copyStaticLauncher --directory artifacts/ - uses: actions/upload-artifact@v7 + if: env.SHOULD_RUN == 'true' with: name: static-launchers path: artifacts/ @@ -1137,27 +1533,38 @@ jobs: retention-days: 2 native-static-tests-default: - needs: generate-static-launcher + needs: [changes, generate-static-launcher] timeout-minutes: 150 runs-on: ubuntu-24.04 + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.test_all == 'true' || needs.changes.outputs.test_native == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping native static integration tests (default) -- changes do not affect compiled code or CI." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" - uses: actions/download-artifact@v8 + if: env.SHOULD_RUN == 'true' with: name: static-launchers path: artifacts/ - name: Build docker image + if: env.SHOULD_RUN == 'true' run: .github/scripts/generate-docker-image.sh - name: Native integration tests + if: env.SHOULD_RUN == 'true' run: ./mill -i integration.test.nativeStatic env: UPDATE_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -1165,49 +1572,60 @@ jobs: SCALA_CLI_IT_GROUP: 1 SCALA_CLI_SODIUM_JNI_ALLOW: false - name: Docker integration tests - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' run: ./mill integration.docker.test - name: Convert Mill test reports to JUnit XML format - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' run: .github/scripts/generate-junit-reports.sc native-static-tests-default 'Scala CLI Native Static Tests (default)' test-report.xml out/ - name: Upload test report uses: actions/upload-artifact@v7 - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' with: name: test-results-native-static-tests-default path: test-report.xml - name: Login to GitHub Container Registry - if: startsWith(github.ref, 'refs/tags/v') + if: startsWith(github.ref, 'refs/tags/v') && env.SHOULD_RUN == 'true' uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Push scala-cli to github container registry - if: startsWith(github.ref, 'refs/tags/v') + if: startsWith(github.ref, 'refs/tags/v') && env.SHOULD_RUN == 'true' run: .github/scripts/publish-docker-images.sh native-static-tests-rc: - needs: generate-static-launcher + needs: [changes, generate-static-launcher] timeout-minutes: 150 runs-on: ubuntu-24.04 + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.test_all == 'true' || needs.changes.outputs.test_native == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping native static integration tests (RC) -- changes do not affect compiled code or CI." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" - uses: actions/download-artifact@v8 + if: env.SHOULD_RUN == 'true' with: name: static-launchers path: artifacts/ - name: Build docker image + if: env.SHOULD_RUN == 'true' run: .github/scripts/generate-docker-image.sh - name: Native integration tests + if: env.SHOULD_RUN == 'true' run: ./mill -i integration.test.nativeStatic env: UPDATE_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -1215,71 +1633,98 @@ jobs: SCALA_CLI_IT_GROUP: 5 SCALA_CLI_SODIUM_JNI_ALLOW: false - name: Convert Mill test reports to JUnit XML format - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' run: .github/scripts/generate-junit-reports.sc native-static-tests-rc 'Scala CLI Native Static Tests (5)' test-report.xml out/ - name: Upload test report uses: actions/upload-artifact@v7 - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' with: name: test-results-native-static-tests-rc path: test-report.xml docs-tests: + needs: [changes] # for now, let's run those tests only on ubuntu runs-on: ubuntu-24.04 + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.docs == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.gifs == 'true' || needs.changes.outputs.test_all == 'true' || needs.changes.outputs.test_docs == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping docs tests -- changes do not affect code, docs, CI, or gifs." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "zulu:17" - uses: actions/setup-node@v6 + if: env.SHOULD_RUN == 'true' with: node-version: 24 - name: Build documentation + if: env.SHOULD_RUN == 'true' run: .github/scripts/build-website.sh - name: Verify release notes formatting + if: env.SHOULD_RUN == 'true' run: .github/scripts/process_release_notes.sc verify website/docs/release_notes.md - name: Test documentation + if: env.SHOULD_RUN == 'true' run: ./mill -i 'docs-tests[]'.test - name: Convert Mill test reports to JUnit XML format - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' run: .github/scripts/generate-junit-reports.sc docs-tests 'Scala CLI Docs Tests' test-report.xml out/ - name: Upload test report uses: actions/upload-artifact@v7 - if: success() || failure() + if: (success() || failure()) && env.SHOULD_RUN == 'true' with: name: test-results-docs-tests path: test-report.xml checks: + needs: [changes] timeout-minutes: 60 runs-on: ubuntu-24.04 + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.docs == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.format_config == 'true' || needs.changes.outputs.test_all == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping checks -- changes do not affect code, docs, CI, or format config." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" - name: Check Scala / Scala.js versions in doc + if: env.SHOULD_RUN == 'true' run: ./mill -i ci.checkScalaVersions - name: Check native-image config format + if: env.SHOULD_RUN == 'true' run: ./mill -i __.checkNativeImageConfFormat - name: Check Ammonite availability + if: env.SHOULD_RUN == 'true' run: ./mill -i 'dummy.amm[_].resolvedRunMvnDeps' - name: Check for cross Scala version conflicts + if: env.SHOULD_RUN == 'true' run: .github/scripts/check-cross-version-deps.sc - name: Scalafix check + if: env.SHOULD_RUN == 'true' run: | ./mill -i __.fix --check || ( echo "To remove unused import run" @@ -1288,31 +1733,50 @@ jobs: ) format: + needs: [changes] timeout-minutes: 15 runs-on: ubuntu-24.04 + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.docs == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.format_config == 'true' || needs.changes.outputs.test_all == 'true' || needs.changes.outputs.test_format == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping format check -- changes do not affect code, docs, CI, or format config." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' - run: scala-cli fmt . --check + if: env.SHOULD_RUN == 'true' reference-doc: + needs: [changes] timeout-minutes: 15 runs-on: ubuntu-24.04 + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.docs == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.test_all == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping reference doc check -- changes do not affect code, docs, or CI." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" - name: Check that reference doc is up-to-date + if: env.SHOULD_RUN == 'true' run: | ./mill -i 'generate-reference-doc[]'.run --check || ( echo "Reference doc is not up-to-date. Run" @@ -1322,68 +1786,104 @@ jobs: ) bloop-memory-footprint: + needs: [changes] timeout-minutes: 120 runs-on: ubuntu-24.04 + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.benchmark == 'true' || needs.changes.outputs.test_all == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping bloop memory footprint benchmark -- changes do not affect code, CI, or benchmarks." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" - name: Java Version + if: env.SHOULD_RUN == 'true' run: java -version - name: Java Home + if: env.SHOULD_RUN == 'true' run: echo "$JAVA_HOME" - name: Build Scala CLI + if: env.SHOULD_RUN == 'true' run: ./mill copyJvmLauncher --directory build - name: Build Benchmark + if: env.SHOULD_RUN == 'true' run: java -jar ./build/scala-cli --power package --standalone gcbenchmark/gcbenchmark.scala -o gc - name: Run Benchmark + if: env.SHOULD_RUN == 'true' run: ./gc $(realpath ./build/scala-cli) test-hypothetical-sbt-export: + needs: [changes] timeout-minutes: 120 runs-on: ubuntu-24.04 + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.test_all == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping sbt export test -- changes do not affect compiled code or CI." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" - name: Try to export to SBT + if: env.SHOULD_RUN == 'true' run: scala-cli --power export --sbt . vc-redist: + needs: [changes] timeout-minutes: 15 runs-on: "windows-2025" if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == 'Virtuslab/scala-cli' + env: + SHOULD_RUN: ${{ needs.changes.outputs.code == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.test_all == 'true' }} steps: + - name: Log skip reason + if: env.SHOULD_RUN != 'true' + run: echo "Skipping vc-redist -- changes do not affect compiled code or CI." - uses: actions/checkout@v6 + if: env.SHOULD_RUN == 'true' with: fetch-depth: 0 submodules: true - name: Import custom registry and verify + if: env.SHOULD_RUN == 'true' uses: ./.github/actions/windows-reg-import with: reg-file: .github/ci/windows/custom.reg - uses: coursier/cache-action@v8 + if: env.SHOULD_RUN == 'true' with: ignoreJob: true - uses: VirtusLab/scala-cli-setup@v1 + if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" - run: ./mill -i ci.copyVcRedist + if: env.SHOULD_RUN == 'true' - uses: actions/upload-artifact@v7 + if: env.SHOULD_RUN == 'true' with: name: vc-redist-launchers path: artifacts/ @@ -1392,6 +1892,7 @@ jobs: publish: needs: + - changes - unit-tests - jvm-bootstrapped-tests-default - jvm-tests-default @@ -1472,6 +1973,7 @@ jobs: launchers: timeout-minutes: 20 needs: + - changes - unit-tests - jvm-bootstrapped-tests-default - jvm-tests-default @@ -1574,6 +2076,7 @@ jobs: update-packages: name: Update packages needs: + - changes - launchers - publish runs-on: ubuntu-24.04 @@ -1669,6 +2172,7 @@ jobs: update-windows-packages: name: Update Windows packages needs: + - changes - launchers - publish runs-on: "windows-2025" diff --git a/DEV.md b/DEV.md index e1f2e678fb..b9cd7e2662 100644 --- a/DEV.md +++ b/DEV.md @@ -282,6 +282,27 @@ There is a script `scala-cli-src` in the repository root that is intended to wor using a binary compiled the worktree. Just add it to your PATH to get the already-released-scala-cli experience. +## CI change detection + +On pull requests, the CI workflow detects which files changed and skips jobs that are not relevant. +Pushes to `main`, `v*` tags, and manual dispatches always run everything. + +### Override keywords + +You can force specific job groups to run regardless of which files changed by including +these keywords anywhere in the PR body (description): + +| Keyword | Effect | +|---------|--------| +| `[test_all]` | Run **all** CI jobs, no skipping | +| `[test_native]` | Force native launcher builds and native integration tests | +| `[test_integration]` | Force JVM integration tests | +| `[test_docs]` | Force documentation tests | +| `[test_format]` | Force format and scalafix checks | + +For example, if your PR only touches documentation, but you want to verify native +launchers still build, add `[test_native]` to the PR description. + ## Releases Instructions on how to From dca2911600ec8cb01361739e9ae7220d920d902a Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Tue, 7 Apr 2026 19:44:00 +0200 Subject: [PATCH 33/64] Add missing attributes to ivy2 publishing (#4203) --- .../scala/cli/commands/publish/Ivy.scala | 106 +++++++++++++++--- .../scala/cli/commands/publish/Publish.scala | 8 +- .../cli/commands/publish/PublishUtils.scala | 11 ++ .../scala/cli/commands/publish/IvyTests.scala | 87 ++++++++++++++ .../PublishLocalTestDefinitions.scala | 72 ++++++++++++ 5 files changed, 270 insertions(+), 14 deletions(-) create mode 100644 modules/cli/src/test/scala/scala/cli/commands/publish/IvyTests.scala diff --git a/modules/cli/src/main/scala/scala/cli/commands/publish/Ivy.scala b/modules/cli/src/main/scala/scala/cli/commands/publish/Ivy.scala index 12ef2e2f1b..503957d44d 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/publish/Ivy.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/publish/Ivy.scala @@ -1,6 +1,6 @@ package scala.cli.commands.publish -import coursier.core.{Configuration, MinimizedExclusions, ModuleName, Organization} +import coursier.core.{Configuration, MinimizedExclusions, ModuleName, Organization, Type} import coursier.publish.Pom import java.time.format.DateTimeFormatterBuilder @@ -8,10 +8,70 @@ import java.time.temporal.ChronoField import java.time.{LocalDateTime, ZoneOffset} import scala.collection.mutable -import scala.xml.NodeSeq +import scala.xml.{Elem, NodeSeq} object Ivy { + private val mavenPomNs = "http://maven.apache.org/POM/4.0.0" + + private def ivyLicenseNodes(license: Option[Pom.License]): NodeSeq = + license match { + case None => NodeSeq.Empty + case Some(l) => + val u = l.url.trim + if u.nonEmpty then + scala.xml.NodeSeq.fromSeq(Seq()) + else + scala.xml.NodeSeq.fromSeq(Seq()) + } + + private def mavenScmNodes(scm: Option[Pom.Scm]): NodeSeq = + scm match { + case None => NodeSeq.Empty + case Some(s) => + val url = s.url.trim + val connection = s.connection.trim + val devConn = s.developerConnection.trim + val children = Seq( + Option.when(url.nonEmpty)({url}), + Option.when(connection.nonEmpty)({connection}), + Option.when(devConn.nonEmpty)( + {devConn} + ) + ).flatten + if children.isEmpty then NodeSeq.Empty + else scala.xml.NodeSeq.fromSeq(Seq({children})) + } + + private def mavenDeveloperNodes(developers: Seq[Pom.Developer]): NodeSeq = + if (developers.isEmpty) NodeSeq.Empty + else { + val devElems = developers.map { d => + val url = d.url.trim + val parts = Seq( + Some({d.id}), + Some({d.name}), + d.mail.map(m => {m}), + Option.when(url.nonEmpty)({url}) + ).flatten + {parts} + } + scala.xml.NodeSeq.fromSeq(Seq({devElems})) + } + + private def mavenProjectNamePackagingNodes( + pomProjectName: Option[String], + packaging: Option[Type] + ): NodeSeq = + val namePart = pomProjectName.flatMap { n => + val t = n.trim + Option.when(t.nonEmpty)({t}) + } + val packagingPart = packaging.map(p => {p.value}) + val parts = namePart.toSeq ++ packagingPart.toSeq + if parts.isEmpty then NodeSeq.Empty + else scala.xml.NodeSeq.fromSeq(parts) + private lazy val dateFormatter = new DateTimeFormatterBuilder() .appendValue(ChronoField.YEAR, 4) .appendValue(ChronoField.MONTH_OF_YEAR, 2) @@ -21,14 +81,17 @@ object Ivy { .appendValue(ChronoField.SECOND_OF_MINUTE, 2) .toFormatter + /** Ivy descriptor aligned with coursier `Pom.create` metadata (license, SCM, developers, optional + * name/packaging). + */ def create( organization: Organization, moduleName: ModuleName, version: String, - // TODO: packaging: Option[Type] = None, description: Option[String] = None, url: Option[String] = None, - // TODO: name: Option[String] = None, + pomProjectName: Option[String] = None, + packaging: Option[Type] = None, // TODO Accept full-fledged coursier.Dependency dependencies: Seq[( Organization, @@ -37,16 +100,21 @@ object Ivy { Option[Configuration], MinimizedExclusions )] = Nil, - // https://github.com/VirtusLab/scala-cli/issues/3914 - // TODO: license: Option[License] = None, - // TODO: scm: Option[Scm] = None, - // TODO: developers: Seq[Developer] = Nil, + license: Option[Pom.License] = None, + scm: Option[Pom.Scm] = None, + developers: Seq[Pom.Developer] = Nil, time: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), hasPom: Boolean = true, hasDoc: Boolean = true, hasSources: Boolean = true ): String = { + val licenseXml = ivyLicenseNodes(license) + val scmXml = mavenScmNodes(scm) + val devXml = mavenDeveloperNodes(developers) + val projectMetaXml = mavenProjectNamePackagingNodes(pomProjectName, packaging) + val hasMavenMetadata = scmXml.nonEmpty || devXml.nonEmpty || projectMetaXml.nonEmpty + val nodes = new mutable.ListBuffer[NodeSeq] nodes += { @@ -63,7 +131,11 @@ object Ivy { + {licenseXml} {desc} + {projectMetaXml} + {scmXml} + {devXml} } @@ -132,11 +204,19 @@ object Ivy { } - Pom.print( - - {nodes.result()} - - ) + val root: Elem = + if (hasMavenMetadata) + + {nodes.result()} + + else + + {nodes.result()} + + + Pom.print(root) } } diff --git a/modules/cli/src/main/scala/scala/cli/commands/publish/Publish.scala b/modules/cli/src/main/scala/scala/cli/commands/publish/Publish.scala index 2f253e011d..1c7724bafc 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/publish/Publish.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/publish/Publish.scala @@ -578,13 +578,15 @@ object Publish extends ScalaCommand[PublishOptions] with BuildCommandHelpers { val description = publishOptions.description.getOrElse(moduleName) logger.debug(s"Published project description: $description") + val pomProjectName = publishOptions.pomProjectNameForMaven(moduleName) + val pomContent = Pom.create( organization = coursier.Organization(org), moduleName = coursier.ModuleName(moduleName), version = ver, packaging = None, url = url, - name = Some(moduleName), // ? + name = Some(pomProjectName), dependencies = dependencies, description = Some(description), license = license, @@ -616,8 +618,12 @@ object Publish extends ScalaCommand[PublishOptions] with BuildCommandHelpers { moduleName = coursier.ModuleName(moduleName), version = ver, url = url, + pomProjectName = Some(pomProjectName), dependencies = dependencies, description = Some(description), + license = license, + scm = scm, + developers = developers, time = LocalDateTime.ofInstant(now, ZoneOffset.UTC), hasDoc = docJarOpt.isDefined, hasSources = sourceJarOpt.isDefined diff --git a/modules/cli/src/main/scala/scala/cli/commands/publish/PublishUtils.scala b/modules/cli/src/main/scala/scala/cli/commands/publish/PublishUtils.scala index b965bb2fff..7bcc78f460 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/publish/PublishUtils.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/publish/PublishUtils.scala @@ -99,6 +99,17 @@ object PublishUtils { } case class ArtifactData(org: String, name: String, version: String) extension (publishOptions: PublishOptions) { + + /** Maven POM `name` / ivy `m:name`: `publish.name` when set, otherwise the published artifact + * name. + */ + def pomProjectNameForMaven(fallbackModuleName: String): String = + publishOptions.name + .map(_.value) + .map(_.trim) + .filter(_.nonEmpty) + .getOrElse(fallbackModuleName) + def artifactData( workspace: os.Path, logger: Logger, diff --git a/modules/cli/src/test/scala/scala/cli/commands/publish/IvyTests.scala b/modules/cli/src/test/scala/scala/cli/commands/publish/IvyTests.scala new file mode 100644 index 0000000000..0a24df8bac --- /dev/null +++ b/modules/cli/src/test/scala/scala/cli/commands/publish/IvyTests.scala @@ -0,0 +1,87 @@ +package scala.cli.commands.publish + +import coursier.core.{ModuleName, Organization, Type} +import coursier.publish.Pom + +import java.time.LocalDateTime + +class IvyTests extends munit.FunSuite { + + test("ivy includes Apache Ivy license and Maven POM scm and developers") { + val organization = Organization("org.example") + val moduleName = ModuleName("demo") + val version = "1.0" + val description = "A demo" + val homepage = "https://example.org" + + val pomProjectName = "Demo library" + val packaging = Type("jar") + + val licenseName = "Apache-2.0" + val licenseUrl = "https://spdx.org/licenses/Apache-2.0.html" + + val scmUrl = "https://github.com/foo/bar.git" + val scmConnection = "scm:git:github.com/foo/bar.git" + val scmDevConnection = "scm:git:git@github.com:foo/bar.git" + + val devId = "jdu" + val devName = "Jane" + val devUrl = "https://jane.example" + val devMail = "jane@example.org" + val fixedTime = LocalDateTime.of(2024, 1, 2, 3, 4, 5) + + val xml = Ivy.create( + organization = organization, + moduleName = moduleName, + version = version, + description = Some(description), + url = Some(homepage), + pomProjectName = Some(pomProjectName), + packaging = Some(packaging), + license = Some(Pom.License(licenseName, licenseUrl)), + scm = Some(Pom.Scm(scmUrl, scmConnection, scmDevConnection)), + developers = Seq( + Pom.Developer(devId, devName, devUrl, Some(devMail)) + ), + dependencies = Nil, + time = fixedTime + ) + assert(xml.contains(s"""$pomProjectName")) + assert(xml.contains(s"${packaging.value}")) + assert(xml.contains(s"$scmUrl")) + assert(xml.contains(s"$scmConnection")) + assert(xml.contains(s"$scmDevConnection")) + assert(xml.contains(s"$devId")) + assert(xml.contains(s"$devName")) + assert(xml.contains(s"$devUrl")) + assert(xml.contains(s"$devMail")) + assert(xml.contains("xmlns:m=")) + } + + test("ivy omits Maven namespace when there is no scm or developer XML") { + val organization = Organization("org.example") + val moduleName = ModuleName("demo") + val version = "1.0" + val licenseName = "MIT" + val licenseUrl = "https://opensource.org/licenses/MIT" + val fixedTime = LocalDateTime.of(2024, 1, 2, 3, 4, 5) + + val xml = Ivy.create( + organization = organization, + moduleName = moduleName, + version = version, + license = Some(Pom.License(licenseName, licenseUrl)), + pomProjectName = None, + packaging = None, + scm = Some(Pom.Scm("", "", "")), + developers = Nil, + time = fixedTime + ) + assert(!xml.contains("xmlns:m=")) + assert(xml.contains(s""" using publish.organization ${PublishTestInputs.testOrg} + |//> using publish.moduleName $modName + |//> using publish.name "$pomProjectName" + |//> using publish.version $testPublishVersion + |//> using publish.license $licenseId:$licenseUrl + |//> using publish.scm github:$vcsOrg/$vcsProj + |//> using publish.developer "$developerDirective" + |""".stripMargin + + TestInputs( + PublishTestInputs.projectFilePath -> PublishTestInputs.projFile("Hello"), + PublishTestInputs.projectConfPath -> publishConf + ).fromRoot { root => + os.proc( + TestUtil.cli, + "--power", + "publish", + "local", + ".", + "--ivy2-home", + os.rel / "ivy2", + extraOptions + ) + .call(cwd = root) + + val ivyPath = root / "ivy2" / "local" / PublishTestInputs.testOrg / modName / + testPublishVersion / "ivys" / "ivy.xml" + val pomPath = root / "ivy2" / "local" / PublishTestInputs.testOrg / modName / + testPublishVersion / "poms" / s"$modName.pom" + expect(os.exists(ivyPath)) + expect(os.exists(pomPath)) + val ivyXml = os.read(ivyPath) + val pomXml = os.read(pomPath) + + expect(ivyXml.contains(s"""$pomProjectName")) + expect(ivyXml.contains("")) + expect(ivyXml.contains("")) + + expect(ivyXml.contains(s"$scmUrl")) + expect(ivyXml.contains(s"$scmConnection")) + expect(ivyXml.contains(s"$scmDevConnection")) + + expect(ivyXml.contains(s"$devId")) + expect(ivyXml.contains(s"$devName")) + expect(ivyXml.contains(s"$devUrl")) + + expect(pomXml.contains(s"$pomProjectName")) + } + } + if actualScalaVersion.startsWith("3") then test("publish local with compileOnly.dep") { TestInputs( From c8abb379a9f9fa482b50a22d2f5f1927006c2f21 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 21:34:33 +0200 Subject: [PATCH 34/64] Bump lodash from 4.17.23 to 4.18.1 in /website (#4212) Bumps [lodash](https://github.com/lodash/lodash) from 4.17.23 to 4.18.1. - [Release notes](https://github.com/lodash/lodash/releases) - [Commits](https://github.com/lodash/lodash/compare/4.17.23...4.18.1) --- updated-dependencies: - dependency-name: lodash dependency-version: 4.18.1 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- website/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/website/yarn.lock b/website/yarn.lock index 2ca96a57a2..bc7ccec590 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -6126,9 +6126,9 @@ lodash.uniq@^4.5.0: integrity sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ== lodash@^4.17.20, lodash@^4.17.21: - version "4.17.23" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.23.tgz#f113b0378386103be4f6893388c73d0bde7f2c5a" - integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w== + version "4.18.1" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c" + integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q== longest-streak@^3.0.0: version "3.1.0" From b6d14720161eabdf683a08b00de74d8219748c91 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Wed, 8 Apr 2026 11:43:35 +0200 Subject: [PATCH 35/64] Add proper handling for `-opt-inline:help` --- .../scala/cli/commands/shared/ScalacOptions.scala | 2 ++ .../scala/scala/cli/integration/HelpTests.scala | 15 +++++++++++++++ .../RunScalacCompatTestDefinitions.scala | 7 ++++++- 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/modules/cli/src/main/scala/scala/cli/commands/shared/ScalacOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/shared/ScalacOptions.scala index 5254989b57..fcccdee279 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/shared/ScalacOptions.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/shared/ScalacOptions.scala @@ -70,6 +70,7 @@ object ScalacOptions { "g", "language", "opt", + "opt-inline", "pagewidth", "page-width", "target", @@ -114,6 +115,7 @@ object ScalacOptions { scalacOptionsPurePrefixes ++ Set( "help", "opt:help", + "opt-inline:help", "Xshow-phases", "Xsource:help", "Xplugin-list", diff --git a/modules/integration/src/test/scala/scala/cli/integration/HelpTests.scala b/modules/integration/src/test/scala/scala/cli/integration/HelpTests.scala index 6f667b80b5..a159e249f5 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/HelpTests.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/HelpTests.scala @@ -72,6 +72,21 @@ class HelpTests extends ScalaCliSuite { "-cp, --jar, --jars, --class, --classes, -classpath, --extra-jar, --classpath, --extra-jars, --class-path, --extra-class, --extra-classes, --extra-class-path paths" )) } + for { + (subcommandLabel, leadArgs) <- + Seq(("compile subcommand", Seq("compile")), ("default subcommand", Seq.empty)) + } test(s"-opt-inline:help works without inputs ($subcommandLabel) (Scala 3.8.3+)") { + TestInputs.empty.fromRoot { root => + val cmd: Seq = Seq(TestUtil.cli) ++ leadArgs ++ + Seq("-S", Constants.scala3Next, "-opt-inline:help") + val res = os.proc(cmd*).call(cwd = root, mergeErrIntoOut = true, check = false) + expect(res.exitCode == 0) + val out = res.out.text() + expect(out.nonEmpty) + expect(out.contains("Inlining requires")) + } + } + for (withPower <- Seq(true, false)) test("envs help" + (if (withPower) " with power" else "")) { val powerOptions = if (withPower) Seq("--power") else Nil diff --git a/modules/integration/src/test/scala/scala/cli/integration/RunScalacCompatTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/RunScalacCompatTestDefinitions.scala index 3b2d445bd5..9ca0a67cfc 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/RunScalacCompatTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/RunScalacCompatTestDefinitions.scala @@ -154,9 +154,14 @@ trait RunScalacCompatTestDefinitions { printOption <- { val printOptionsForAllVersions = Seq("-X", "-Xshow-phases", "-Xplugin-list", "-Y") val printOptionsScala213OrHigher = Seq("-V", "-Vphases", "-W", "-Xsource:help") + val printOptionsScala38Help = Seq("-opt-inline:help") val printOptionsScala2 = Seq("-Xlint:help", "-opt:help", "-Xmixin-force-forwarders:help") actualScalaVersion match { - case v if v.startsWith("3") => printOptionsForAllVersions ++ printOptionsScala213OrHigher + case v if v.startsWith("3") => + val scala38Help = + if v.coursierVersion >= "3.8.3".coursierVersion then printOptionsScala38Help + else Nil + printOptionsForAllVersions ++ printOptionsScala213OrHigher ++ scala38Help case v if v.startsWith("2.13") => printOptionsForAllVersions ++ printOptionsScala213OrHigher ++ printOptionsScala2 case v if v.startsWith("2.12") => printOptionsForAllVersions ++ printOptionsScala2 From 401a4ddba5314f52a8f64e59653ab0bee3e51aab Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Wed, 8 Apr 2026 13:17:13 +0200 Subject: [PATCH 36/64] Add simplistic `*:help` compiler print help options detection --- .../scala/cli/commands/ScalaCommand.scala | 2 +- .../cli/commands/shared/ScalacOptions.scala | 21 +++++++---- .../cli/tests/ScalacOptionsPrintTest.scala | 35 +++++++++++++++++++ .../scala/cli/integration/HelpTests.scala | 12 ++++--- 4 files changed, 57 insertions(+), 13 deletions(-) create mode 100644 modules/cli/src/test/scala/scala/cli/tests/ScalacOptionsPrintTest.scala diff --git a/modules/cli/src/main/scala/scala/cli/commands/ScalaCommand.scala b/modules/cli/src/main/scala/scala/cli/commands/ScalaCommand.scala index dbfa275e9c..4122c98ab4 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/ScalaCommand.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/ScalaCommand.scala @@ -192,7 +192,7 @@ abstract class ScalaCommand[T <: HasGlobalOptions](implicit myParser: Parser[T], shared <- sharedOptions(options) scalacOptions = shared.scalacOptions updatedScalacOptions = scalacOptions.withScalacExtraOptions(shared.scalacExtra) - if updatedScalacOptions.map(_.noDashPrefixes).exists(ScalacOptions.ScalacPrintOptions) + if updatedScalacOptions.map(_.noDashPrefixes).exists(ScalacOptions.isScalacPrintOption) logger = shared.logger fixedBuildOptions = buildOptions.copy(scalaOptions = buildOptions.scalaOptions.copy(defaultScalaVersion = Some(ScalaCli.getDefaultScalaVersion)) diff --git a/modules/cli/src/main/scala/scala/cli/commands/shared/ScalacOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/shared/ScalacOptions.scala index fcccdee279..b06f688d14 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/shared/ScalacOptions.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/shared/ScalacOptions.scala @@ -108,22 +108,29 @@ object ScalacOptions { "unique-id" ) ++ replNoArgAliasedOptions - /** This includes all the scalac options which disregard inputs and print a help and/or context - * message instead. + /** True when the token ends with a `:help` suffix, with any number of colon-separated segments + * before it (e.g. `Xlint:help`, `opt:a:b:c:help`). Used together with `ScalacPrintOptions` so + * new compiler `…:help` flags work without listing each one. */ + def isColonHelpPrintOption(noDashPrefixes: String): Boolean = + noDashPrefixes.endsWith(":help") + + /** `scalac` options that print help or context and exit without requiring source inputs. */ val ScalacPrintOptions: Set[String] = scalacOptionsPurePrefixes ++ Set( "help", - "opt:help", - "opt-inline:help", "Xshow-phases", - "Xsource:help", "Xplugin-list", - "Xmixin-force-forwarders:help", - "Xlint:help", "Vphases" ) + /** Whether `ScalaCommand.maybePrintSimpleScalacOutput` should run for this token (after + * `ScalacOpt.noDashPrefixes`). + */ + def isScalacPrintOption(noDashPrefixes: String): Boolean = + ScalacPrintOptions.contains(noDashPrefixes) || + isColonHelpPrintOption(noDashPrefixes) + /** This includes all the scalac options which are redirected to native Scala CLI options. */ val ScalaCliRedirectedOptions: Set[String] = Set( "classpath", diff --git a/modules/cli/src/test/scala/scala/cli/tests/ScalacOptionsPrintTest.scala b/modules/cli/src/test/scala/scala/cli/tests/ScalacOptionsPrintTest.scala new file mode 100644 index 0000000000..7bd4c02997 --- /dev/null +++ b/modules/cli/src/test/scala/scala/cli/tests/ScalacOptionsPrintTest.scala @@ -0,0 +1,35 @@ +package scala.cli.tests + +import munit.FunSuite + +import scala.cli.commands.shared.ScalacOptions + +final class ScalacOptionsPrintTest extends FunSuite { + + test("isColonHelpPrintOption: :help suffix (single segment)") { + assert(ScalacOptions.isColonHelpPrintOption("opt-inline:help")) + assert(ScalacOptions.isColonHelpPrintOption("Xlint:help")) + assert(ScalacOptions.isColonHelpPrintOption("opt:help")) + } + + test("isColonHelpPrintOption: :help suffix (multi-level colons)") { + assert(ScalacOptions.isColonHelpPrintOption("opt:l:inline:help")) + assert(ScalacOptions.isColonHelpPrintOption("a:b:help")) + assert(ScalacOptions.isColonHelpPrintOption("foo:bar:baz:help")) + } + + test("isColonHelpPrintOption: reject non-suffix") { + assert(!ScalacOptions.isColonHelpPrintOption("help")) + assert(!ScalacOptions.isColonHelpPrintOption("Xlint:infer-any")) + assert(!ScalacOptions.isColonHelpPrintOption("help:foo")) + assert(!ScalacOptions.isColonHelpPrintOption("something:helpme")) + } + + test("isScalacPrintOption: combines explicit set and :help rule") { + assert(ScalacOptions.isScalacPrintOption("Xshow-phases")) + assert(ScalacOptions.isScalacPrintOption("help")) + assert(ScalacOptions.isScalacPrintOption("Xsource:help")) + assert(ScalacOptions.isScalacPrintOption("opt:l:inline:help")) + assert(!ScalacOptions.isScalacPrintOption("random-flag")) + } +} diff --git a/modules/integration/src/test/scala/scala/cli/integration/HelpTests.scala b/modules/integration/src/test/scala/scala/cli/integration/HelpTests.scala index a159e249f5..75e973dbb2 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/HelpTests.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/HelpTests.scala @@ -73,13 +73,15 @@ class HelpTests extends ScalaCliSuite { )) } for { - (subcommandLabel, leadArgs) <- - Seq(("compile subcommand", Seq("compile")), ("default subcommand", Seq.empty)) + (subcommandLabel, leadArgs) <- Seq( + ("compile subcommand", Seq("compile")), + ("default subcommand", Seq.empty) + ) } test(s"-opt-inline:help works without inputs ($subcommandLabel) (Scala 3.8.3+)") { TestInputs.empty.fromRoot { root => - val cmd: Seq = Seq(TestUtil.cli) ++ leadArgs ++ - Seq("-S", Constants.scala3Next, "-opt-inline:help") - val res = os.proc(cmd*).call(cwd = root, mergeErrIntoOut = true, check = false) + val res = + os.proc(TestUtil.cli, leadArgs, "-S", Constants.scala3Next, "-opt-inline:help") + .call(cwd = root, mergeErrIntoOut = true, check = false) expect(res.exitCode == 0) val out = res.out.text() expect(out.nonEmpty) From 85744693309ed1ecbcd4cc9b6216d1df4b6ac97d Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Wed, 8 Apr 2026 17:06:49 +0200 Subject: [PATCH 37/64] Bump Scala 3 Next RC to 3.8.4-RC1 (#4213) --- project/deps/package.mill | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/project/deps/package.mill b/project/deps/package.mill index 246354df80..f6499d70e6 100644 --- a/project/deps/package.mill +++ b/project/deps/package.mill @@ -21,8 +21,8 @@ object Scala { def scala3NextPrefix = "3.8" def scala3Next = s"$scala3NextPrefix.3" // the newest/next version of Scala def scala3NextAnnounced = s"$scala3NextPrefix.2" // the newest/next version of Scala that's been announced - def scala3NextRc = "3.8.3-RC3" // the latest RC version of Scala Next - def scala3NextRcAnnounced = "3.8.3-RC2" // the latest announced RC version of Scala Next + def scala3NextRc = "3.8.4-RC1" // the latest RC version of Scala Next + def scala3NextRcAnnounced = "3.8.3-RC3" // the latest announced RC version of Scala Next // The Scala version used to build the CLI itself. def defaultInternal = sys.props.get("scala.version.internal").getOrElse(scala3Lts) From 0d4614385e971f7d8c6e32b17f8fc8cd2073e432 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Wed, 8 Apr 2026 17:34:45 +0200 Subject: [PATCH 38/64] Bump Mill to 1.1.5 (was 1.1.3) (#4217) --- .mill-version | 2 +- mill.bat | 2 +- millw | 2 +- website/docs/reference/cli-options.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.mill-version b/.mill-version index 781dcb07cd..e25d8d9f35 100644 --- a/.mill-version +++ b/.mill-version @@ -1 +1 @@ -1.1.3 +1.1.5 diff --git a/mill.bat b/mill.bat index 8a7f54e414..948392f36d 100755 --- a/mill.bat +++ b/mill.bat @@ -2,7 +2,7 @@ setlocal enabledelayedexpansion -if [!DEFAULT_MILL_VERSION!]==[] ( set "DEFAULT_MILL_VERSION=1.1.3" ) +if [!DEFAULT_MILL_VERSION!]==[] ( set "DEFAULT_MILL_VERSION=1.1.5" ) if [!MILL_GITHUB_RELEASE_CDN!]==[] ( set "MILL_GITHUB_RELEASE_CDN=" ) diff --git a/millw b/millw index 77380ebba9..155baa79d7 100755 --- a/millw +++ b/millw @@ -2,7 +2,7 @@ set -e -if [ -z "${DEFAULT_MILL_VERSION}" ] ; then DEFAULT_MILL_VERSION="1.1.3"; fi +if [ -z "${DEFAULT_MILL_VERSION}" ] ; then DEFAULT_MILL_VERSION="1.1.5"; fi if [ -z "${GITHUB_RELEASE_CDN}" ] ; then GITHUB_RELEASE_CDN=""; fi diff --git a/website/docs/reference/cli-options.md b/website/docs/reference/cli-options.md index 1beb1411e8..a4d5376657 100644 --- a/website/docs/reference/cli-options.md +++ b/website/docs/reference/cli-options.md @@ -388,7 +388,7 @@ Version of SBT to be used for the export (1.12.4 by default) ### `--mill-version` -Version of Mill to be used for the export (1.1.3 by default) +Version of Mill to be used for the export (1.1.5 by default) ### `--mvn-version` From ced20dd3ba3ea70db3d90042a7ea41a0e3bf98c3 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Thu, 9 Apr 2026 07:55:31 +0200 Subject: [PATCH 39/64] Run tests with JDK 26 (#4214) --- project/deps/package.mill | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/deps/package.mill b/project/deps/package.mill index f6499d70e6..993171d125 100644 --- a/project/deps/package.mill +++ b/project/deps/package.mill @@ -105,7 +105,7 @@ object Java { defaultJava, minimumJavaLauncherJava ).distinct - def mainJavaVersions: Seq[Int] = Seq(8, 11, 17, 21, 23, 24, 25) + def mainJavaVersions: Seq[Int] = Seq(8, 11, 17, 21, 23, 24, 25, 26) def allJavaVersions: Seq[Int] = (mainJavaVersions ++ cliKeyJavaVersions).distinct } From fbc634d33242c5b30392887fc757d922b7668249 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Thu, 9 Apr 2026 11:21:39 +0200 Subject: [PATCH 40/64] Split release_notes.md into a separate test per-release-tag in `docs-tests` (#4216) --- .../src/test/scala/sclicheck/DocTests.scala | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/modules/docs-tests/src/test/scala/sclicheck/DocTests.scala b/modules/docs-tests/src/test/scala/sclicheck/DocTests.scala index 1e617f574d..df8f43eb02 100644 --- a/modules/docs-tests/src/test/scala/sclicheck/DocTests.scala +++ b/modules/docs-tests/src/test/scala/sclicheck/DocTests.scala @@ -3,6 +3,7 @@ package sclicheck import java.util.concurrent.TimeUnit import scala.concurrent.duration.FiniteDuration +import scala.util.matching.Regex class DocTests extends munit.FunSuite { override def munitTimeout = new FiniteDuration(600, TimeUnit.SECONDS) @@ -19,6 +20,14 @@ class DocTests extends munit.FunSuite { val options: Options = Options(scalaCliCommand = Seq(TestUtil.scalaCliPath.toString)) + private val ReleaseNotesMd = os.rel / "release_notes.md" + + /** `## [v1.12.0](https://…)` style headings that delimit per-version release note sections. */ + private val ReleaseVersionHeading: Regex = """^##\s+\[(v[\w.\-]+)\]""".r + + private def isReleaseVersionHeadingLine(line: String): Boolean = + ReleaseVersionHeading.findFirstMatchIn(line).nonEmpty + private def lineContainsAnyChecks(l: String): Boolean = l.startsWith("```md") || l.startsWith("```bash") || l.startsWith("```scala compile") || l.startsWith("```scala fail") || @@ -27,6 +36,27 @@ class DocTests extends munit.FunSuite { private def fileContainsAnyChecks(f: os.Path): Boolean = os.read.lines(f).exists(lineContainsAnyChecks) + /** One sclicheck run per `## [v…]` section so each gets its own timeout and workspace (Option C). + */ + private def releaseNotesSections(file: os.Path): Seq[(String, IndexedSeq[String])] = + val lines = os.read.lines(file).toIndexedSeq + val starts = lines.zipWithIndex.collect { + case (line, i) if isReleaseVersionHeadingLine(line) => i + } + if starts.isEmpty && fileContainsAnyChecks(file) then Seq(("release_notes", lines)) + else if starts.isEmpty then Nil + else + starts.zipWithIndex.map { case (startIdx, chunkIdx) => + val endIdx = + if chunkIdx + 1 < starts.size then starts(chunkIdx + 1) + else lines.size + val slice = + if chunkIdx == 0 then lines.slice(0, endIdx) + else lines.slice(startIdx, endIdx) + val ver = ReleaseVersionHeading.findFirstMatchIn(lines(startIdx)).get.group(1) + (ver, slice) + }.filter { case (_, slice) => slice.exists(lineContainsAnyChecks) } + for { DocTestEntry(tpe, dir, depth) <- entries inputs = os.walk(dir, maxDepth = depth) @@ -36,9 +66,24 @@ class DocTests extends munit.FunSuite { .map(_.relativeTo(dir)) .sortBy(_.toString) md <- inputs + if !(tpe == "root" && md == ReleaseNotesMd) } test(s"$tpe ${md.toString.stripSuffix(".md")}") { TestUtil.retryOnCi()(checkFile(dir / md, options)) } + private val releaseNotesFile = docsRootPath / "release_notes.md" + if os.isFile(releaseNotesFile) && fileContainsAnyChecks(releaseNotesFile) then + for (ver, slice) <- releaseNotesSections(releaseNotesFile) do + val safeStem = ver.replaceAll("[^a-zA-Z0-9._\\-]", "_") + test(s"root release_notes $ver") { + TestUtil.retryOnCi() { + TestUtil.withTmpDir("sclicheck-release-notes") { tmp => + val chunkFile = tmp / s"release_notes-$safeStem.md" + os.write.over(chunkFile, slice.mkString("", "\n", "\n")) + checkFile(chunkFile, options) + } + } + } + } From b11b3a8d02f6245f9d6411f16b4a01a29e49f6e1 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Thu, 9 Apr 2026 07:49:54 +0200 Subject: [PATCH 41/64] Bump Ammonite to 3.0.9 (was 3.0.8) --- project/deps/package.mill | 2 +- website/docs/reference/cli-options.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/project/deps/package.mill b/project/deps/package.mill index 993171d125..311cc1d8b7 100644 --- a/project/deps/package.mill +++ b/project/deps/package.mill @@ -120,7 +120,7 @@ object TestDeps { object Deps { object Versions { - def ammonite = "3.0.8" + def ammonite = "3.0.9" def ammoniteForScala3Lts = ammonite def argonautShapeless = "1.3.1" // jni-utils version may need to be sync-ed when bumping the coursier version diff --git a/website/docs/reference/cli-options.md b/website/docs/reference/cli-options.md index a4d5376657..11ff0d21c7 100644 --- a/website/docs/reference/cli-options.md +++ b/website/docs/reference/cli-options.md @@ -1272,7 +1272,7 @@ Use Ammonite (instead of the default Scala REPL) Aliases: `--ammonite-ver` -Set the Ammonite version (3.0.8 by default) +Set the Ammonite version (3.0.9 by default) ### `--ammonite-arg` From f5639233f88cb6e47ab5c291c4480c31de6dd000 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Thu, 9 Apr 2026 14:45:03 +0200 Subject: [PATCH 42/64] Add deprecation mechanism for CLI options, directives, sub-commands, and config keys --- .../build/PersistentDiagnosticLogger.scala | 5 + .../build/internal/util/WarningMessages.scala | 30 +++++ .../preprocessing/DeprecatedDirectives.scala | 43 +++--- .../scala/build/tests/BuildProjectTests.scala | 7 + .../scala/build/tests/DeprecationTests.scala | 101 ++++++++++++++ .../scala/scala/build/tests/TestLogger.scala | 16 +++ .../cli/commands/RestrictableCommand.scala | 7 + .../commands/RestrictedCommandsParser.scala | 13 +- .../scala/cli/commands/ScalaCommand.scala | 17 +++ .../scala/cli/commands/config/Config.scala | 5 + .../cli/commands/shared/SharedOptions.scala | 15 ++- .../scala/scala/cli/internal/CliLogger.scala | 24 ++++ .../scala/scala/cli/util/ArgHelpers.scala | 18 ++- .../src/main/scala/scala/cli/config/Key.scala | 6 +- .../main/scala/scala/cli/config/Keys.scala | 10 ++ .../src/main/scala/scala/build/Logger.scala | 6 + .../scala/cli/doc/GenerateReferenceDoc.scala | 9 +- .../cli/integration/DeprecationTests.scala | 127 ++++++++++++++++++ .../options/SuppressWarningOptions.scala | 3 +- .../cli/commands/SpecificationLevel.scala | 5 + website/docs/reference/cli-options.md | 14 ++ website/docs/reference/commands.md | 1 + .../reference/scala-command/cli-options.md | 16 +++ .../docs/reference/scala-command/commands.md | 1 + .../scala-command/runner-specification.md | 91 +++++++++++++ 25 files changed, 560 insertions(+), 30 deletions(-) create mode 100644 modules/build/src/test/scala/scala/build/tests/DeprecationTests.scala create mode 100644 modules/integration/src/test/scala/scala/cli/integration/DeprecationTests.scala diff --git a/modules/build/src/main/scala/scala/build/PersistentDiagnosticLogger.scala b/modules/build/src/main/scala/scala/build/PersistentDiagnosticLogger.scala index 6e10265efb..dbedc693f2 100644 --- a/modules/build/src/main/scala/scala/build/PersistentDiagnosticLogger.scala +++ b/modules/build/src/main/scala/scala/build/PersistentDiagnosticLogger.scala @@ -46,4 +46,9 @@ class PersistentDiagnosticLogger(parent: Logger) extends Logger { parent.experimentalWarning(featureName, featureType) def flushExperimentalWarnings: Unit = parent.flushExperimentalWarnings + + def deprecationWarning(featureName: String, message: String, featureType: FeatureType): Unit = + parent.deprecationWarning(featureName, message, featureType) + + def flushDeprecationWarnings: Unit = parent.flushDeprecationWarnings } diff --git a/modules/build/src/main/scala/scala/build/internal/util/WarningMessages.scala b/modules/build/src/main/scala/scala/build/internal/util/WarningMessages.scala index f8a5230dde..996a9bb241 100644 --- a/modules/build/src/main/scala/scala/build/internal/util/WarningMessages.scala +++ b/modules/build/src/main/scala/scala/build/internal/util/WarningMessages.scala @@ -130,9 +130,39 @@ object WarningMessages { val mainScriptNameClashesWithAppWrapper = "Script file named 'main.sc' detected, keep in mind that accessing it from other scripts is impossible due to a clash of `main` symbols" + private val deprecationNote = + "Deprecated features may be removed in a future version." + + private def formatDeprecationEntry( + name: String, + detail: String, + featureType: FeatureType + ): String = + val suffix = if detail.nonEmpty then s" $detail" else "" + s"`$name` $featureType is deprecated.$suffix" + + def deprecatedFeaturesUsed(namesMessagesAndTypes: Seq[(String, String, FeatureType)]): String = { + val message = namesMessagesAndTypes match { + case Seq((name, detail, featureType)) => + formatDeprecationEntry(name, detail, featureType) + case entries => + val nl = System.lineSeparator() + val bulletPoints = entries.map((name, detail, ft) => + s" - ${formatDeprecationEntry(name, detail, ft)}" + ).mkString(nl) + s"""Some utilized features are deprecated: + |$bulletPoints""".stripMargin + } + s"""[${Console.YELLOW}warn${Console.RESET}] $message + |$deprecationNote""".stripMargin + } + def deprecatedWarning(old: String, `new`: String) = s"Using '$old' is deprecated, use '${`new`}' instead" + def deprecatedWarningForRemoval(name: String) = + s"Using '$name' is deprecated and will be removed in a future version" + def deprecatedToolkitLatest(updatedValue: String = "") = if updatedValue.isEmpty then """Using 'latest' for toolkit is deprecated, use 'default' to get more stable behaviour""" diff --git a/modules/build/src/main/scala/scala/build/preprocessing/DeprecatedDirectives.scala b/modules/build/src/main/scala/scala/build/preprocessing/DeprecatedDirectives.scala index 1cb11a4d8d..0de7545572 100644 --- a/modules/build/src/main/scala/scala/build/preprocessing/DeprecatedDirectives.scala +++ b/modules/build/src/main/scala/scala/build/preprocessing/DeprecatedDirectives.scala @@ -3,7 +3,11 @@ package scala.build.preprocessing import scala.build.Logger import scala.build.errors.Diagnostic.TextEdit import scala.build.internal.Constants -import scala.build.internal.util.WarningMessages.{deprecatedToolkitLatest, deprecatedWarning} +import scala.build.internal.util.WarningMessages.{ + deprecatedToolkitLatest, + deprecatedWarning, + deprecatedWarningForRemoval +} import scala.build.options.SuppressWarningOptions import scala.build.preprocessing.directives.{DirectiveHandler, StrictDirective, Toolkit} import scala.build.warnings.DeprecatedWarning @@ -23,13 +27,16 @@ object DeprecatedDirectives { (values.isEmpty || values.contains(foundValues)) } - private type WarningAndReplacement = (String, DirectiveTemplate) + private type WarningAndReplacement = (String, Option[DirectiveTemplate]) private def keyReplacement(replacement: String)(warning: String): WarningAndReplacement = - (warning, DirectiveTemplate(Seq(replacement), None)) + (warning, Some(DirectiveTemplate(Seq(replacement), None))) private def valueReplacement(replacements: String*)(warning: String): WarningAndReplacement = - (warning, DirectiveTemplate(Nil, Some(replacements.toSeq))) + (warning, Some(DirectiveTemplate(Nil, Some(replacements.toSeq)))) + + private def noReplacement(warning: String): WarningAndReplacement = + (warning, None) private def allKeysFrom(handler: DirectiveHandler[?]): Seq[String] = handler.keys.flatMap(_.nameAliases) @@ -61,6 +68,12 @@ object DeprecatedDirectives { Some(Seq(s"${Constants.typelevelOrganization}:latest")) ) -> valueReplacement(s"${Toolkit.typelevel}:default")( deprecatedToolkitLatest() + ), + DirectiveTemplate(Seq("deprecatedTestDirective"), None) -> keyReplacement("testDirective")( + deprecatedWarning("deprecatedTestDirective", "testDirective") + ), + DirectiveTemplate(Seq("deprecatedForRemovalTestDirective"), None) -> noReplacement( + deprecatedWarningForRemoval("deprecatedForRemovalTestDirective") ) ) @@ -78,19 +91,15 @@ object DeprecatedDirectives { if !suppressWarningOptions.suppressDeprecatedFeatureWarning.getOrElse(false) then directives.map(d => d -> warningAndReplacement(d)) .foreach { - case (directive, Some(warning, replacement)) => - val newKey = replacement.keys.headOption.getOrElse(directive.key) - val newValues = replacement.values.getOrElse(directive.toStringValues) - val newText = s"$newKey ${newValues.mkString(" ")}" - - // TODO use key and/or value positions instead of whole directive - val position = directive.position(path) - - val diagnostic = DeprecatedWarning( - warning, - Seq(position), - Some(TextEdit(s"Change to: $newText", newText)) - ) + case (directive, Some(warning, replacementOpt)) => + val position = directive.position(path) + val textEditOpt = replacementOpt.map { replacement => + val newKey = replacement.keys.headOption.getOrElse(directive.key) + val newValues = replacement.values.getOrElse(directive.toStringValues) + val newText = s"$newKey ${newValues.mkString(" ")}" + TextEdit(s"Change to: $newText", newText) + } + val diagnostic = DeprecatedWarning(warning, Seq(position), textEditOpt) logger.log(Seq(diagnostic)) case _ => () } diff --git a/modules/build/src/test/scala/scala/build/tests/BuildProjectTests.scala b/modules/build/src/test/scala/scala/build/tests/BuildProjectTests.scala index 6657cc5938..bf9cf324da 100644 --- a/modules/build/src/test/scala/scala/build/tests/BuildProjectTests.scala +++ b/modules/build/src/test/scala/scala/build/tests/BuildProjectTests.scala @@ -51,6 +51,13 @@ class BuildProjectTests extends TestUtil.ScalaCliBuildSuite { override def experimentalWarning(featureName: String, featureType: FeatureType): Unit = System.err.println(s"experimental: $featureName") override def flushExperimentalWarnings: Unit = () + override def deprecationWarning( + featureName: String, + message: String, + featureType: FeatureType + ): Unit = + System.err.println(s"deprecated: $featureName: $message") + override def flushDeprecationWarnings: Unit = () } test("workspace for bsp") { diff --git a/modules/build/src/test/scala/scala/build/tests/DeprecationTests.scala b/modules/build/src/test/scala/scala/build/tests/DeprecationTests.scala new file mode 100644 index 0000000000..13a3b0884e --- /dev/null +++ b/modules/build/src/test/scala/scala/build/tests/DeprecationTests.scala @@ -0,0 +1,101 @@ +package scala.build.tests + +import com.eed3si9n.expecty.Expecty.expect + +import scala.build.errors.Diagnostic +import scala.build.internal.util.WarningMessages +import scala.build.internals.FeatureType +import scala.build.options.SuppressWarningOptions +import scala.build.preprocessing.DeprecatedDirectives +import scala.build.preprocessing.directives.StrictDirective +import scala.collection.mutable.ListBuffer + +class DeprecationTests extends TestUtil.ScalaCliBuildSuite { + + test("deprecatedFeaturesUsed formats single feature with name prefix") { + val msg = WarningMessages.deprecatedFeaturesUsed( + Seq(("--some-option", "Use --other instead.", FeatureType.Option)) + ) + expect(msg.contains("`--some-option` option is deprecated.")) + expect(msg.contains("Use --other instead.")) + expect(msg.contains("Deprecated features may be removed")) + } + + test("deprecatedFeaturesUsed formats single feature with no detail") { + val msg = WarningMessages.deprecatedFeaturesUsed( + Seq(("--old-alias", "", FeatureType.Option)) + ) + expect(msg.contains("`--old-alias` option is deprecated.")) + expect(!msg.contains("is deprecated. ")) + expect(msg.contains("Deprecated features may be removed")) + } + + test("deprecatedFeaturesUsed formats multiple features with name prefix") { + val msg = WarningMessages.deprecatedFeaturesUsed(Seq( + ("--opt-a", "Use --opt-b.", FeatureType.Option), + ("my-command", "Use other-command.", FeatureType.Subcommand) + )) + expect(msg.contains("`--opt-a` option is deprecated. Use --opt-b.")) + expect(msg.contains("`my-command` sub-command is deprecated. Use other-command.")) + expect(msg.contains("Deprecated features may be removed")) + } + + private class DiagnosticCapturingLogger extends TestLogger() { + val diagnostics: ListBuffer[Diagnostic] = ListBuffer.empty + override def log(diags: Seq[Diagnostic]): Unit = + diagnostics ++= diags + } + + test("DeprecatedDirectives detects deprecatedTestDirective") { + val directive = StrictDirective("deprecatedTestDirective", Seq.empty) + val logger = new DiagnosticCapturingLogger() + DeprecatedDirectives.issueWarnings( + Left("test.scala"), + Seq(directive), + SuppressWarningOptions(), + logger + ) + expect(logger.diagnostics.exists(_.message.contains("deprecatedTestDirective"))) + } + + test("DeprecatedDirectives suppresses warnings when configured") { + val directive = StrictDirective("deprecatedTestDirective", Seq.empty) + val logger = new DiagnosticCapturingLogger() + DeprecatedDirectives.issueWarnings( + Left("test.scala"), + Seq(directive), + SuppressWarningOptions(suppressDeprecatedFeatureWarning = Some(true)), + logger + ) + expect(logger.diagnostics.isEmpty) + } + + test("DeprecatedDirectives deprecated for removal emits warning without TextEdit") { + val directive = StrictDirective("deprecatedForRemovalTestDirective", Seq.empty) + val logger = new DiagnosticCapturingLogger() + DeprecatedDirectives.issueWarnings( + Left("test.scala"), + Seq(directive), + SuppressWarningOptions(), + logger + ) + val diag = logger.diagnostics.find(_.message.contains("deprecatedForRemovalTestDirective")) + expect(diag.isDefined) + expect(diag.get.message.contains("removed in a future version")) + expect(diag.get.textEdit.isEmpty) + } + + test("DeprecatedDirectives key replacement emits warning with TextEdit") { + val directive = StrictDirective("deprecatedTestDirective", Seq.empty) + val logger = new DiagnosticCapturingLogger() + DeprecatedDirectives.issueWarnings( + Left("test.scala"), + Seq(directive), + SuppressWarningOptions(), + logger + ) + val diag = logger.diagnostics.find(_.message.contains("deprecatedTestDirective")) + expect(diag.isDefined) + expect(diag.get.textEdit.isDefined) + } +} diff --git a/modules/build/src/test/scala/scala/build/tests/TestLogger.scala b/modules/build/src/test/scala/scala/build/tests/TestLogger.scala index bd6841d75d..530f52f080 100644 --- a/modules/build/src/test/scala/scala/build/tests/TestLogger.scala +++ b/modules/build/src/test/scala/scala/build/tests/TestLogger.scala @@ -45,6 +45,13 @@ final class RecordingLogger(delegate: Logger = TestLogger()) extends Logger { override def experimentalWarning(featureName: String, featureType: FeatureType): Unit = delegate.experimentalWarning(featureName, featureType) override def flushExperimentalWarnings: Unit = delegate.flushExperimentalWarnings + override def deprecationWarning( + featureName: String, + message: String, + featureType: FeatureType + ): Unit = + delegate.deprecationWarning(featureName, message, featureType) + override def flushDeprecationWarnings: Unit = delegate.flushDeprecationWarnings } case class TestLogger(info: Boolean = true, debug: Boolean = false) extends Logger { @@ -123,4 +130,13 @@ case class TestLogger(info: Boolean = true, debug: Boolean = false) extends Logg System.err.println(s"Experimental $featureType `$featureName` used") override def flushExperimentalWarnings: Unit = () + + override def deprecationWarning( + featureName: String, + message: String, + featureType: FeatureType + ): Unit = + System.err.println(s"Deprecated $featureType `$featureName`: $message") + + override def flushDeprecationWarnings: Unit = () } diff --git a/modules/cli/src/main/scala/scala/cli/commands/RestrictableCommand.scala b/modules/cli/src/main/scala/scala/cli/commands/RestrictableCommand.scala index 40e6535338..73859d8649 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/RestrictableCommand.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/RestrictableCommand.scala @@ -27,6 +27,13 @@ trait RestrictableCommand[T](implicit myParser: Parser[T]) { /** Is that command a MUST / SHOULD / NICE TO have for the Scala runner specification? */ def scalaSpecificationLevel: SpecificationLevel + + /** Override to mark the entire sub-command as deprecated. */ + def deprecationMessage: Option[String] = None + + /** Override to mark specific command name aliases as deprecated. */ + def deprecatedNames: Set[List[String]] = Set.empty + // To reduce imports... protected def SpecificationLevel = scala.cli.commands.SpecificationLevel } diff --git a/modules/cli/src/main/scala/scala/cli/commands/RestrictedCommandsParser.scala b/modules/cli/src/main/scala/scala/cli/commands/RestrictedCommandsParser.scala index d964b97a18..fbb0670708 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/RestrictedCommandsParser.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/RestrictedCommandsParser.scala @@ -56,14 +56,19 @@ object RestrictedCommandsParser { if arg.isExperimental && !shouldSuppressExperimentalWarnings => logger.experimentalWarning(passedOption, FeatureType.Option) r + case (r @ Right(Some(_, arg: Arg, _)), passedOption :: _) + if arg.isDeprecatedOption && !shouldSuppressDeprecatedWarnings => + logger.deprecationWarning( + passedOption, + arg.deprecationMessage.getOrElse(""), + FeatureType.Option + ) + r case (r @ Right(Some(_, arg: Arg, _)), passedOption :: _) if arg.isDeprecated && !shouldSuppressDeprecatedWarnings => - // TODO implement proper deprecation logic: https://github.com/VirtusLab/scala-cli/issues/3258 arg.deprecatedOptionAliases.find(_ == passedOption) .foreach { deprecatedAlias => - logger.message( - s"""[${Console.YELLOW}warn${Console.RESET}] The $deprecatedAlias option alias has been deprecated and may be removed in a future version.""" - ) + logger.deprecationWarning(deprecatedAlias, "", FeatureType.Option) } r case (other, _) => diff --git a/modules/cli/src/main/scala/scala/cli/commands/ScalaCommand.scala b/modules/cli/src/main/scala/scala/cli/commands/ScalaCommand.scala index 4122c98ab4..f3cf2a0618 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/ScalaCommand.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/ScalaCommand.scala @@ -390,6 +390,22 @@ abstract class ScalaCommand[T <: HasGlobalOptions](implicit myParser: Parser[T], else if isExperimental && !shouldSuppressExperimentalFeatureWarnings then logger.experimentalWarning(name, FeatureType.Subcommand) + if !shouldSuppressDeprecatedFeatureWarnings then + deprecationMessage match + case Some(msg) => + logger.deprecationWarning(actualCommandName, msg, FeatureType.Subcommand) + case None => + val usedNames = argvOpt.map { argv => + val maxLen = names.map(_.length).max max 1 + argv.slice(1, maxLen + 1).toList + }.getOrElse(List(name)) + names.find(_ == usedNames) + .filter(deprecatedNames.contains) + .foreach { depName => + val aliasStr = depName.mkString(" ") + logger.deprecationWarning(aliasStr, "", FeatureType.Subcommand) + } + maybePrintWarnings(options) maybePrintGroupHelp(options) buildOptions(options).foreach { bo => @@ -398,6 +414,7 @@ abstract class ScalaCommand[T <: HasGlobalOptions](implicit myParser: Parser[T], } maybePrintEnvsHelp(options) logger.flushExperimentalWarnings + logger.flushDeprecationWarnings runCommand(options, remainingArgs, options.global.logging.logger) } } diff --git a/modules/cli/src/main/scala/scala/cli/commands/config/Config.scala b/modules/cli/src/main/scala/scala/cli/commands/config/Config.scala index 1e95199966..2358eb175d 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/config/Config.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/config/Config.scala @@ -112,6 +112,10 @@ object Config extends ScalaCommand[ConfigOptions] { case Some(entry) => if entry.isExperimental && !shouldSuppressExperimentalFeatureWarnings then logger.experimentalWarning(entry.fullName, FeatureType.ConfigKey) + if !shouldSuppressDeprecatedFeatureWarnings then + entry.deprecationMessage.foreach { msg => + logger.deprecationWarning(entry.fullName, msg, FeatureType.ConfigKey) + } if (values.isEmpty) if (options.unset) { db.remove(entry) @@ -290,6 +294,7 @@ object Config extends ScalaCommand[ConfigOptions] { } logger.flushExperimentalWarnings + logger.flushDeprecationWarnings } /** Check whether to ask for an update depending on the provided key. diff --git a/modules/cli/src/main/scala/scala/cli/commands/shared/SharedOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/shared/SharedOptions.scala index 6be30799ea..4ea59433fd 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/shared/SharedOptions.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/shared/SharedOptions.scala @@ -211,7 +211,20 @@ final case class SharedOptions( @Tag(tags.experimental) objectWrapper: Option[Boolean] = None, @Recurse - scope: ScopeOptions = ScopeOptions() + scope: ScopeOptions = ScopeOptions(), + + @Hidden + @Tag(tags.implementation) + @Tag(tags.deprecatedOption("For testing purposes only.")) + @HelpMessage("Deprecated test option (internal, do not use)") + deprecatedTestOption: Option[Boolean] = None, + + @Hidden + @Tag(tags.implementation) + @Name("deprecatedTestAlias") + @Tag(tags.deprecated("deprecatedTestAlias")) + @HelpMessage("Option with deprecated alias (internal, do not use)") + deprecatedTestAliasOption: Option[Boolean] = None ) extends HasGlobalOptions { // format: on diff --git a/modules/cli/src/main/scala/scala/cli/internal/CliLogger.scala b/modules/cli/src/main/scala/scala/cli/internal/CliLogger.scala index fa2c86e3b0..c17f0a0bcf 100644 --- a/modules/cli/src/main/scala/scala/cli/internal/CliLogger.scala +++ b/modules/cli/src/main/scala/scala/cli/internal/CliLogger.scala @@ -132,6 +132,7 @@ class CliLogger( printEx(ex, new mutable.HashMap) def exit(ex: BuildException): Nothing = flushExperimentalWarnings + flushDeprecationWarnings if (verbosity < 0) sys.exit(1) else if (verbosity == 0) { @@ -233,6 +234,29 @@ class CliLogger( experimentalWarnings = Map.empty } + private var deprecationWarnings: Map[FeatureType, Map[String, String]] = Map.empty + private var reportedDeprecations: Map[FeatureType, Set[String]] = Map.empty + def deprecationWarning(featureName: String, msg: String, featureType: FeatureType): Unit = + if !reportedDeprecations.get(featureType).exists(_.contains(featureName)) then + deprecationWarnings = deprecationWarnings.updatedWith(featureType) { + case None => Some(Map(featureName -> msg)) + case Some(entries) => Some(entries + (featureName -> msg)) + } + def flushDeprecationWarnings: Unit = if deprecationWarnings.nonEmpty then + val entries = + for + (featureType, nameMap) <- deprecationWarnings.toSeq.sortBy(_._1) + (name, msg) <- nameMap + yield (name, msg, featureType) + val messageStr = WarningMessages.deprecatedFeaturesUsed(entries) + message(messageStr) + reportedDeprecations = + for + (featureType, nameMap) <- deprecationWarnings + alreadyReported = reportedDeprecations.getOrElse(featureType, Set.empty[String]) + yield featureType -> (nameMap.keySet ++ alreadyReported) + deprecationWarnings = Map.empty + override def cliFriendlyDiagnostic( message: String, cliFriendlyMessage: String, diff --git a/modules/cli/src/main/scala/scala/cli/util/ArgHelpers.scala b/modules/cli/src/main/scala/scala/cli/util/ArgHelpers.scala index 09d217d60f..4e3cfb2739 100644 --- a/modules/cli/src/main/scala/scala/cli/util/ArgHelpers.scala +++ b/modules/cli/src/main/scala/scala/cli/util/ArgHelpers.scala @@ -15,12 +15,16 @@ object ArgHelpers { private def hasTag(tag: String): Boolean = arg.tags.exists(_.name == tag) private def hasTagPrefix(tagPrefix: String): Boolean = arg.tags.exists(_.name.startsWith(tagPrefix)) - def isExperimental: Boolean = arg.hasTag(tags.experimental) - def isRestricted: Boolean = arg.hasTag(tags.restricted) - def isDeprecated: Boolean = arg.hasTagPrefix(tags.deprecatedPrefix) + def isExperimental: Boolean = arg.hasTag(tags.experimental) + def isRestricted: Boolean = arg.hasTag(tags.restricted) + def isDeprecated: Boolean = arg.hasTagPrefix(tags.deprecatedPrefix) + def isDeprecatedOption: Boolean = arg.hasTagPrefix(tags.deprecatedOptionPrefix) def deprecatedNames: List[String] = arg.tags - .filter(_.name.startsWith(tags.deprecatedPrefix)) + .filter(t => + t.name.startsWith(tags.deprecatedPrefix) && + !t.name.startsWith(tags.deprecatedOptionPrefix) + ) .map(_.name.stripPrefix(s"${tags.deprecatedPrefix}${tags.valueSeparator}")) .toList @@ -32,6 +36,12 @@ object ArgHelpers { ).mkString("-") } + def deprecationMessage: Option[String] = arg.tags + .find(_.name.startsWith(tags.deprecatedOptionPrefix)) + .map(_.name.stripPrefix(s"${tags.deprecatedOptionPrefix}${tags.valueSeparator}")) + .map(_.stripPrefix(tags.deprecatedOptionPrefix)) + .filter(_.nonEmpty) + def isExperimentalOrRestricted: Boolean = arg.isRestricted || arg.isExperimental def isSupported: Boolean = ScalaCli.allowRestrictedFeatures || !arg.isExperimentalOrRestricted diff --git a/modules/config/src/main/scala/scala/cli/config/Key.scala b/modules/config/src/main/scala/scala/cli/config/Key.scala index f747b8249e..a351947bdf 100644 --- a/modules/config/src/main/scala/scala/cli/config/Key.scala +++ b/modules/config/src/main/scala/scala/cli/config/Key.scala @@ -56,6 +56,9 @@ abstract class Key[T] { def isExperimental: Boolean = specificationLevel == SpecificationLevel.EXPERIMENTAL def isRestricted: Boolean = specificationLevel == SpecificationLevel.RESTRICTED + + def deprecationMessage: Option[String] = None + def isDeprecated: Boolean = deprecationMessage.isDefined } object Key { @@ -127,7 +130,8 @@ object Key { val name: String, override val specificationLevel: SpecificationLevel, val description: String = "", - override val hidden: Boolean = false + override val hidden: Boolean = false, + override val deprecationMessage: Option[String] = None ) extends KeyWithJsonCodec[Boolean] { def asString(value: Boolean): Seq[String] = Seq(value.toString) diff --git a/modules/config/src/main/scala/scala/cli/config/Keys.scala b/modules/config/src/main/scala/scala/cli/config/Keys.scala index 3359f883cd..16dd6328fc 100644 --- a/modules/config/src/main/scala/scala/cli/config/Keys.scala +++ b/modules/config/src/main/scala/scala/cli/config/Keys.scala @@ -148,6 +148,15 @@ object Keys { // Kept for binary compatibility val repositoriesMirrors: Key.StringListEntry = repositoryMirrors + val deprecatedTestKey = new Key.BooleanEntry( + prefix = Seq("test"), + name = "deprecated-key", + specificationLevel = SpecificationLevel.IMPLEMENTATION, + description = "Deprecated test key (internal, do not use).", + hidden = true, + deprecationMessage = Some("For testing purposes only.") + ) + // setting indicating if the global interactive mode was suggested val globalInteractiveWasSuggested = new Key.BooleanEntry( prefix = Seq.empty, @@ -176,6 +185,7 @@ object Keys { def all: Seq[Key[?]] = Seq[Key[?]]( actions, defaultRepositories, + deprecatedTestKey, ghToken, globalInteractiveWasSuggested, interactive, diff --git a/modules/core/src/main/scala/scala/build/Logger.scala b/modules/core/src/main/scala/scala/build/Logger.scala index 8e41c81c95..eecba9508a 100644 --- a/modules/core/src/main/scala/scala/build/Logger.scala +++ b/modules/core/src/main/scala/scala/build/Logger.scala @@ -48,6 +48,9 @@ trait Logger { def experimentalWarning(featureName: String, featureType: FeatureType): Unit def flushExperimentalWarnings: Unit + def deprecationWarning(featureName: String, message: String, featureType: FeatureType): Unit + def flushDeprecationWarnings: Unit + def cliFriendlyDiagnostic( message: String, @unused cliFriendlyMessage: String, @@ -93,6 +96,9 @@ object Logger { def experimentalWarning(featureUsed: String, featureType: FeatureType): Unit = () def flushExperimentalWarnings: Unit = () + def deprecationWarning(featureName: String, message: String, featureType: FeatureType): Unit = + () + def flushDeprecationWarnings: Unit = () } def nop: Logger = new Nop } diff --git a/modules/generate-reference-doc/src/main/scala/scala/cli/doc/GenerateReferenceDoc.scala b/modules/generate-reference-doc/src/main/scala/scala/cli/doc/GenerateReferenceDoc.scala index 8c854f9ae4..9adf1eab31 100644 --- a/modules/generate-reference-doc/src/main/scala/scala/cli/doc/GenerateReferenceDoc.scala +++ b/modules/generate-reference-doc/src/main/scala/scala/cli/doc/GenerateReferenceDoc.scala @@ -203,8 +203,10 @@ object GenerateReferenceDoc extends CaseApp[InternalDocOptions] { for (arg <- distinctArgs) { import caseapp.core.util.NameOps._ arg.name.option(nameFormatter) - val names = (arg.name +: arg.extraNames).map(_.option(nameFormatter)) - b.append(s"### `${names.head}`\n\n") + val names = (arg.name +: arg.extraNames).map(_.option(nameFormatter)) + val deprecatedLabel = + if arg.isDeprecatedOption then "[deprecated] " else "" + b.append(s"### $deprecatedLabel`${names.head}`\n\n") if (names.tail.nonEmpty) b.append( names @@ -217,6 +219,9 @@ object GenerateReferenceDoc extends CaseApp[InternalDocOptions] { } .mkString("Aliases: ", ", ", "\n\n") ) + arg.deprecationMessage.foreach { msg => + b.append(s"**Deprecated**: $msg\n\n") + } if (onlyRestricted) b.section(s"`${arg.level.md}` per Scala Runner specification") diff --git a/modules/integration/src/test/scala/scala/cli/integration/DeprecationTests.scala b/modules/integration/src/test/scala/scala/cli/integration/DeprecationTests.scala new file mode 100644 index 0000000000..1d4d5ee03c --- /dev/null +++ b/modules/integration/src/test/scala/scala/cli/integration/DeprecationTests.scala @@ -0,0 +1,127 @@ +package scala.cli.integration + +import com.eed3si9n.expecty.Expecty.expect + +class DeprecationTests extends ScalaCliSuite { + override def group: ScalaCliSuite.TestGroup = ScalaCliSuite.TestGroup.First + + private val configFile = os.rel / "config" / "config.json" + private val configEnvs = Map("SCALA_CLI_CONFIG" -> configFile.toString()) + + test("deprecated CLI option warning includes exact option name and detail") { + val inputPath = os.rel / "example.sc" + TestInputs(inputPath -> """println("hello")""").fromRoot { root => + val res = os.proc( + TestUtil.cli, + "run", + TestUtil.extraOptions, + inputPath, + "--deprecated-test-option" + ).call(cwd = root, stderr = os.Pipe) + val err = res.err.trim() + expect(err.contains("--deprecated-test-option")) + expect(err.contains("is deprecated.")) + expect(err.contains("For testing purposes only.")) + } + } + + test("deprecated CLI option alias warning includes exact alias name") { + val inputPath = os.rel / "example.sc" + TestInputs(inputPath -> """println("hello")""").fromRoot { root => + val res = os.proc( + TestUtil.cli, + "run", + TestUtil.extraOptions, + inputPath, + "--deprecated-test-alias" + ).call(cwd = root, stderr = os.Pipe) + val err = res.err.trim() + expect(err.contains("--deprecated-test-alias")) + expect(err.contains("is deprecated.")) + } + } + + test("deprecated using directive produces a warning") { + val inputPath = os.rel / "example.sc" + TestInputs(inputPath -> + """//> using lib "com.lihaoyi::os-lib:0.11.4" + |println("hello") + |""".stripMargin).fromRoot { root => + val res = os.proc(TestUtil.cli, "run", TestUtil.extraOptions, inputPath) + .call(cwd = root, stderr = os.Pipe) + val err = res.err.trim() + expect(err.contains("deprecated")) + expect(err.contains("lib")) + } + } + + test("--suppress-deprecated-warnings silences deprecation warnings") { + val inputPath = os.rel / "example.sc" + TestInputs(inputPath -> """println("hello")""").fromRoot { root => + val res = os.proc( + TestUtil.cli, + "run", + TestUtil.extraOptions, + inputPath, + "--deprecated-test-option", + "--suppress-deprecated-warnings" + ).call(cwd = root, stderr = os.Pipe) + val err = res.err.trim() + expect(!err.contains("is deprecated")) + } + } + + test("config suppress-warning.deprecated-features silences deprecation warnings") { + val inputPath = os.rel / "example.sc" + TestInputs(inputPath -> """println("hello")""").fromRoot { root => + os.proc(TestUtil.cli, "config", "suppress-warning.deprecated-features", "true") + .call(cwd = root, env = configEnvs) + val res = os.proc( + TestUtil.cli, + "run", + TestUtil.extraOptions, + inputPath, + "--deprecated-test-option" + ).call(cwd = root, stderr = os.Pipe, env = configEnvs) + val err = res.err.trim() + expect(!err.contains("is deprecated")) + } + } + + test("--ammonite deprecation warning includes the exact alias used") { + TestInputs.empty.fromRoot { root => + val res = os.proc( + TestUtil.cli, + "--power", + "repl", + TestUtil.extraOptions, + "--amm", + "--repl-dry-run" + ).call(cwd = root, stderr = os.Pipe) + val err = res.err.trim() + expect(err.contains("--amm")) + expect(err.contains("is deprecated.")) + expect(err.contains("Use the default Scala REPL instead.")) + } + } + + test("multiple deprecated features produce a single consolidated warning") { + val inputPath = os.rel / "example.sc" + TestInputs(inputPath -> """println("hello")""").fromRoot { root => + val res = os.proc( + TestUtil.cli, + "run", + TestUtil.extraOptions, + inputPath, + "--deprecated-test-option", + "--deprecated-test-alias" + ).call(cwd = root, stderr = os.Pipe) + val err = res.err.trim() + expect(err.contains("--deprecated-test-option")) + expect(err.contains("--deprecated-test-alias")) + val deprecatedWarningLines = err.linesIterator + .count(_.contains("Deprecated features may be removed")) + expect(deprecatedWarningLines == 1) + } + } +} diff --git a/modules/options/src/main/scala/scala/build/options/SuppressWarningOptions.scala b/modules/options/src/main/scala/scala/build/options/SuppressWarningOptions.scala index c0269f2196..0adfa2355d 100644 --- a/modules/options/src/main/scala/scala/build/options/SuppressWarningOptions.scala +++ b/modules/options/src/main/scala/scala/build/options/SuppressWarningOptions.scala @@ -14,6 +14,7 @@ object SuppressWarningOptions { val suppressAll = SuppressWarningOptions( suppressDirectivesInMultipleFilesWarning = Some(true), suppressOutdatedDependencyWarning = Some(true), - suppressExperimentalFeatureWarning = Some(true) + suppressExperimentalFeatureWarning = Some(true), + suppressDeprecatedFeatureWarning = Some(true) ) } diff --git a/modules/specification-level/src/main/scala/scala/cli/commands/SpecificationLevel.scala b/modules/specification-level/src/main/scala/scala/cli/commands/SpecificationLevel.scala index 5764cc89dd..8eeecfc22a 100644 --- a/modules/specification-level/src/main/scala/scala/cli/commands/SpecificationLevel.scala +++ b/modules/specification-level/src/main/scala/scala/cli/commands/SpecificationLevel.scala @@ -75,6 +75,11 @@ object tags { def deprecated(name: String): String = s"$deprecatedPrefix$valueSeparator$name" // produces a deprecated warning for the given name + val deprecatedOptionPrefix: String = "deprecatedOption" + def deprecatedOption: String = deprecatedOptionPrefix + def deprecatedOption(message: String): String = + s"$deprecatedOptionPrefix$valueSeparator$message" + def levelFor(name: String): Option[SpecificationLevel] = name match { case `experimental` => Some(SpecificationLevel.EXPERIMENTAL) case `restricted` => Some(SpecificationLevel.RESTRICTED) diff --git a/website/docs/reference/cli-options.md b/website/docs/reference/cli-options.md index 11ff0d21c7..5bf8505774 100644 --- a/website/docs/reference/cli-options.md +++ b/website/docs/reference/cli-options.md @@ -1707,6 +1707,20 @@ Exclude sources Force object wrapper for scripts +### [deprecated] `--deprecated-test-option` + +**Deprecated**: For testing purposes only. + +[Internal] +Deprecated test option (internal, do not use) + +### `--deprecated-test-alias-option` + +Aliases: [deprecated] `--deprecated-test-alias` + +[Internal] +Option with deprecated alias (internal, do not use) + ## Snippet options Available in commands: diff --git a/website/docs/reference/commands.md b/website/docs/reference/commands.md index 64ef81f38d..5e7ccd12fb 100644 --- a/website/docs/reference/commands.md +++ b/website/docs/reference/commands.md @@ -72,6 +72,7 @@ Available keys: - suppress-warning.directives-in-multiple-files Globally suppresses warnings about directives declared in multiple source files. - suppress-warning.experimental-features Globally suppresses warnings about experimental features. - suppress-warning.outdated-dependencies-files Globally suppresses warnings about outdated dependencies. + - test.deprecated-key Deprecated test key (internal, do not use). For detailed documentation refer to our website: https://scala-cli.virtuslab.org/docs/commands/config diff --git a/website/docs/reference/scala-command/cli-options.md b/website/docs/reference/scala-command/cli-options.md index 3a2faa185f..88d7a60235 100644 --- a/website/docs/reference/scala-command/cli-options.md +++ b/website/docs/reference/scala-command/cli-options.md @@ -1164,6 +1164,22 @@ Add toolkit to classPath (not supported in Scala 2.12), 'default' version for Sc Exclude sources +### [deprecated] `--deprecated-test-option` + +**Deprecated**: For testing purposes only. + +`IMPLEMENTATION specific` per Scala Runner specification + +Deprecated test option (internal, do not use) + +### `--deprecated-test-alias-option` + +Aliases: [deprecated] `--deprecated-test-alias` + +`IMPLEMENTATION specific` per Scala Runner specification + +Option with deprecated alias (internal, do not use) + ## Snippet options Available in commands: diff --git a/website/docs/reference/scala-command/commands.md b/website/docs/reference/scala-command/commands.md index 0d8583fa93..a13403a694 100644 --- a/website/docs/reference/scala-command/commands.md +++ b/website/docs/reference/scala-command/commands.md @@ -71,6 +71,7 @@ Available keys: - suppress-warning.directives-in-multiple-files Globally suppresses warnings about directives declared in multiple source files. - suppress-warning.experimental-features Globally suppresses warnings about experimental features. - suppress-warning.outdated-dependencies-files Globally suppresses warnings about outdated dependencies. + - test.deprecated-key Deprecated test key (internal, do not use). For detailed documentation refer to our website: https://scala-cli.virtuslab.org/docs/commands/config diff --git a/website/docs/reference/scala-command/runner-specification.md b/website/docs/reference/scala-command/runner-specification.md index 7ab255dab7..58a9462945 100644 --- a/website/docs/reference/scala-command/runner-specification.md +++ b/website/docs/reference/scala-command/runner-specification.md @@ -650,6 +650,16 @@ Aliases: `--toolkit` Exclude sources +**--deprecated-test-option** + +Deprecated test option (internal, do not use) + +**--deprecated-test-alias-option** + +Option with deprecated alias (internal, do not use) + +Aliases: `--deprecated-test-alias` + --- @@ -693,6 +703,7 @@ Available keys: - suppress-warning.directives-in-multiple-files Globally suppresses warnings about directives declared in multiple source files. - suppress-warning.experimental-features Globally suppresses warnings about experimental features. - suppress-warning.outdated-dependencies-files Globally suppresses warnings about outdated dependencies. + - test.deprecated-key Deprecated test key (internal, do not use). For detailed documentation refer to our website: https://scala-cli.virtuslab.org/docs/commands/config @@ -1441,6 +1452,16 @@ Aliases: `--toolkit` Exclude sources +**--deprecated-test-option** + +Deprecated test option (internal, do not use) + +**--deprecated-test-alias-option** + +Option with deprecated alias (internal, do not use) + +Aliases: `--deprecated-test-alias` + --- @@ -2050,6 +2071,16 @@ Aliases: `--toolkit` Exclude sources +**--deprecated-test-option** + +Deprecated test option (internal, do not use) + +**--deprecated-test-alias-option** + +Option with deprecated alias (internal, do not use) + +Aliases: `--deprecated-test-alias` + **--java-prop-option** Add java properties. Note that options equal `-Dproperty=value` are assumed to be java properties and don't require to be passed after `--java-prop`. @@ -2689,6 +2720,16 @@ Aliases: `--toolkit` Exclude sources +**--deprecated-test-option** + +Deprecated test option (internal, do not use) + +**--deprecated-test-alias-option** + +Option with deprecated alias (internal, do not use) + +Aliases: `--deprecated-test-alias` + **--java-prop-option** Add java properties. Note that options equal `-Dproperty=value` are assumed to be java properties and don't require to be passed after `--java-prop`. @@ -3337,6 +3378,16 @@ Aliases: `--toolkit` Exclude sources +**--deprecated-test-option** + +Deprecated test option (internal, do not use) + +**--deprecated-test-alias-option** + +Option with deprecated alias (internal, do not use) + +Aliases: `--deprecated-test-alias` + **--java-prop-option** Add java properties. Note that options equal `-Dproperty=value` are assumed to be java properties and don't require to be passed after `--java-prop`. @@ -3943,6 +3994,16 @@ Aliases: `--toolkit` Exclude sources +**--deprecated-test-option** + +Deprecated test option (internal, do not use) + +**--deprecated-test-alias-option** + +Option with deprecated alias (internal, do not use) + +Aliases: `--deprecated-test-alias` + **--respect-project-filters** Use project filters defined in the configuration. Turned on by default, use `--respect-project-filters:false` to disable it. @@ -4627,6 +4688,16 @@ Aliases: `--toolkit` Exclude sources +**--deprecated-test-option** + +Deprecated test option (internal, do not use) + +**--deprecated-test-alias-option** + +Option with deprecated alias (internal, do not use) + +Aliases: `--deprecated-test-alias` + **--java-prop-option** Add java properties. Note that options equal `-Dproperty=value` are assumed to be java properties and don't require to be passed after `--java-prop`. @@ -5321,6 +5392,16 @@ Aliases: `--toolkit` Exclude sources +**--deprecated-test-option** + +Deprecated test option (internal, do not use) + +**--deprecated-test-alias-option** + +Option with deprecated alias (internal, do not use) + +Aliases: `--deprecated-test-alias` + **--json-options** Command-line options JSON file @@ -6298,6 +6379,16 @@ Aliases: `--toolkit` Exclude sources +**--deprecated-test-option** + +Deprecated test option (internal, do not use) + +**--deprecated-test-alias-option** + +Option with deprecated alias (internal, do not use) + +Aliases: `--deprecated-test-alias` + **--bsp-directory** Custom BSP configuration location From f98a01f9dfd320db064cd8955e07fd52d7f6ecae Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Thu, 9 Apr 2026 14:45:39 +0200 Subject: [PATCH 43/64] Deprecate `--ammonite`, `--ammonite-version`, and `--ammonite-arg` for removal --- .../scala/cli/commands/repl/SharedReplOptions.scala | 3 +++ website/docs/commands/repl.md | 4 ++++ website/docs/reference/cli-options.md | 12 +++++++++--- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/modules/cli/src/main/scala/scala/cli/commands/repl/SharedReplOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/repl/SharedReplOptions.scala index e4fc7e671a..451eea975e 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/repl/SharedReplOptions.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/repl/SharedReplOptions.scala @@ -18,6 +18,7 @@ final case class SharedReplOptions( @Group(HelpGroup.Repl.toString) @Tag(tags.restricted) @Tag(tags.inShortHelp) + @Tag(tags.deprecatedOption("Ammonite integration is deprecated. Use the default Scala REPL instead.")) @HelpMessage("Use Ammonite (instead of the default Scala REPL)") @Name("A") @Name("amm") @@ -25,6 +26,7 @@ final case class SharedReplOptions( @Group(HelpGroup.Repl.toString) @Tag(tags.restricted) + @Tag(tags.deprecatedOption("Ammonite integration is deprecated. Use the default Scala REPL instead.")) @HelpMessage(s"Set the Ammonite version (${Constants.ammoniteVersion} by default)") @Name("ammoniteVer") @Tag(tags.inShortHelp) @@ -34,6 +36,7 @@ final case class SharedReplOptions( @Name("a") @Tag(tags.restricted) @Tag(tags.inShortHelp) + @Tag(tags.deprecatedOption("Ammonite integration is deprecated. Use the default Scala REPL instead.")) @HelpMessage("Provide arguments for ammonite repl") @Hidden ammoniteArg: List[String] = Nil, diff --git a/website/docs/commands/repl.md b/website/docs/commands/repl.md index 650cd3ef37..921c73dfcf 100644 --- a/website/docs/commands/repl.md +++ b/website/docs/commands/repl.md @@ -26,6 +26,10 @@ Scala CLI by default uses the normal Scala REPL. If you prefer to use the [Ammonite REPL](https://ammonite.io/#Ammonite-REPL), specify `--amm` to launch it rather than the default REPL: +:::warning +The Ammonite integration (`--ammonite` / `--amm` / `-A` and related options) is **deprecated** and will be removed in a future version. Use the default Scala REPL instead. +::: + :::caution Using the Ammonite REPL is restricted and requires setting the `--power` option to be used. You can pass it explicitly or set it globally by running: diff --git a/website/docs/reference/cli-options.md b/website/docs/reference/cli-options.md index 5bf8505774..fa7cae7702 100644 --- a/website/docs/reference/cli-options.md +++ b/website/docs/reference/cli-options.md @@ -1262,22 +1262,28 @@ Available in commands: -### `--ammonite` +### [deprecated] `--ammonite` Aliases: `-A`, `--amm` +**Deprecated**: Ammonite integration is deprecated. Use the default Scala REPL instead. + Use Ammonite (instead of the default Scala REPL) -### `--ammonite-version` +### [deprecated] `--ammonite-version` Aliases: `--ammonite-ver` +**Deprecated**: Ammonite integration is deprecated. Use the default Scala REPL instead. + Set the Ammonite version (3.0.9 by default) -### `--ammonite-arg` +### [deprecated] `--ammonite-arg` Aliases: `-a` +**Deprecated**: Ammonite integration is deprecated. Use the default Scala REPL instead. + [Internal] Provide arguments for ammonite repl From 5f468378db6f1febdcce76c769856131f498f88a Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Thu, 9 Apr 2026 14:45:43 +0200 Subject: [PATCH 44/64] Add an agent skill for deprecating features --- agentskills/deprecating-features/SKILL.md | 102 ++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 agentskills/deprecating-features/SKILL.md diff --git a/agentskills/deprecating-features/SKILL.md b/agentskills/deprecating-features/SKILL.md new file mode 100644 index 0000000000..9ee46c7c81 --- /dev/null +++ b/agentskills/deprecating-features/SKILL.md @@ -0,0 +1,102 @@ +--- +name: scala-cli-deprecating-features +description: Deprecate CLI options, option aliases, using directives, sub-commands, or config keys in Scala CLI. Use when marking a feature as deprecated with a warning. +--- + +# Deprecating features (Scala CLI) + +All deprecation mechanisms emit aggregated warnings (single consolidated message) via `Logger.deprecationWarning` / `Logger.flushDeprecationWarnings`, respecting suppression via `--suppress-deprecated-warnings` or `config suppress-warning.deprecated-features true`. + +## Warning format + +The formatter always prefixes with the exact name used and the feature type: + +- Single: `` [warn] `--foo` option is deprecated. Use --bar instead.\nDeprecated features may be removed in a future version. `` +- Multiple: consolidated bullet-point list with each entry prefixed by name and type + +The `message`/`detail` passed by callers should NOT repeat the feature name — only provide the reason or migration hint. Pass `""` for no extra detail. + +## 1. Deprecate a CLI option (entire option) + +In the options case class, add `@Tag(tags.deprecatedOption("detail"))` or `@Tag(tags.deprecatedOption)` (no detail): + +```scala +@Tag(tags.deprecatedOption("Use --bar instead.")) + foo: Option[Boolean] = None, +``` + +- Fires for **any** name/alias of the option — the exact alias used is shown in the warning +- Detected in `RestrictedCommandsParser` via `arg.isDeprecatedOption` + +## 2. Deprecate a CLI option alias + +Add `@Tag(tags.deprecated("aliasName"))` alongside the `@Name("aliasName")`: + +```scala +@Name("oldAlias") +@Tag(tags.deprecated("oldAlias")) + myOption: Option[Boolean] = None, +``` + +- Fires only when the specific alias is used + +## 3. Deprecate a using directive (key or value) + +Add an entry to `DeprecatedDirectives.deprecatedCombinationsAndReplacements` in `modules/build/.../preprocessing/DeprecatedDirectives.scala`: + +```scala +// Key swap (e.g. lib → dep): +DirectiveTemplate(Seq("oldKey"), None) -> keyReplacement("newKey")( + deprecatedWarning("oldKey", "newKey") +) + +// Deprecated for removal (no replacement): +DirectiveTemplate(Seq("removedKey"), None) -> noReplacement( + deprecatedWarningForRemoval("removedKey") +) +``` + +- `keyReplacement` / `valueReplacement` — swap to a new key or value, emits a `TextEdit` for IDE quick-fix +- `noReplacement` — deprecated for removal, no `TextEdit` offered +- Emitted as a positioned `Diagnostic` (supports BSP with source locations) +- Not aggregated (kept as individual diagnostics for IDE support) + +## 4. Deprecate a sub-command + +Override `deprecationMessage` in the command object (detail only, name is auto-prefixed): + +```scala +object MyCommand extends ScalaCommand[MyOptions] { + override def deprecationMessage: Option[String] = + Some("Use other-command instead.") +} +``` + +For deprecating only a specific command alias, override `deprecatedNames`: + +```scala +override def deprecatedNames: Set[List[String]] = Set(List("old-alias")) +``` + +## 5. Deprecate a config key + +Pass `deprecationMessage` to the `Key` constructor (currently supported on `BooleanEntry`): + +```scala +val myKey = new Key.BooleanEntry( + prefix = Seq("my"), + name = "key", + specificationLevel = SpecificationLevel.IMPLEMENTATION, + description = "...", + deprecationMessage = Some("Use my.new-key instead.") +) +``` + +- Warning emitted in `Config.scala` when the key is accessed + +## Post-deprecation checklist + +1. Run `./mill -i __.compile` +2. Run relevant tests +3. Run `./mill -i 'generate-reference-doc[]'.run` (deprecated options/aliases are marked in reference docs) +4. Update user-facing documentation if needed From 7136dbdd8ef23b59f4f55eff638dca59c7f26421 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Fri, 10 Apr 2026 08:15:43 +0200 Subject: [PATCH 45/64] NIT Fix misc compiler warnings --- .../scala/build/postprocessing/SemanticdbProcessor.scala | 1 + .../main/scala/scala/cli/commands/package0/Package.scala | 2 +- .../cli/src/main/scala/scala/cli/commands/run/Run.scala | 2 +- modules/cli/src/test/scala/cli/tests/ScalafmtTests.scala | 8 ++++++-- .../src/main/scala/scala/build/internals/OsLibc.scala | 2 +- .../docs-tests/src/main/scala/sclicheck/sclicheck.scala | 2 +- .../main/scala/scala/build/options/ConfigMonoid.scala | 9 +++------ 7 files changed, 14 insertions(+), 12 deletions(-) diff --git a/modules/build/src/main/scala/scala/build/postprocessing/SemanticdbProcessor.scala b/modules/build/src/main/scala/scala/build/postprocessing/SemanticdbProcessor.scala index cfd7b0b820..ab5dc6e75f 100644 --- a/modules/build/src/main/scala/scala/build/postprocessing/SemanticdbProcessor.scala +++ b/modules/build/src/main/scala/scala/build/postprocessing/SemanticdbProcessor.scala @@ -69,6 +69,7 @@ object SemanticdbProcessor { for { fun <- updateTree(t.function) } yield t.withFunction(fun) + case _ => Some(tree) } if (os.isFile(orig)) { diff --git a/modules/cli/src/main/scala/scala/cli/commands/package0/Package.scala b/modules/cli/src/main/scala/scala/cli/commands/package0/Package.scala index a5828a837d..0ddda4e82c 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/package0/Package.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/package0/Package.scala @@ -915,7 +915,7 @@ object Package extends ScalaCommand[PackageOptions] with BuildCommandHelpers { val modulesSet = modules.toSet val providedDeps: Seq[core.Dependency] = value { res - .map(_.dependencyArtifacts0.safeArtifacts.map(_.map(_._1))) + .map(_.dependencyArtifacts0().safeArtifacts.map(_.map(_._1))) .sequence .left .map(CompositeBuildException(_)) diff --git a/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala b/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala index c13b1e15ce..e22e6bedc8 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala @@ -602,7 +602,7 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers { case s: MarkdownFile => fwd(s.path.toString) case _: SbtFile => "" case s: OnDisk => fwd(s.path.toString) - case s => s.getClass.getName + case null => "" }.filter(_.nonEmpty).distinct val sources = sourceFiles.mkString(File.pathSeparator) val sourceNames = sourceFiles.map(base).mkString(File.pathSeparator) diff --git a/modules/cli/src/test/scala/cli/tests/ScalafmtTests.scala b/modules/cli/src/test/scala/cli/tests/ScalafmtTests.scala index 7d39b00afe..76a81d3d44 100644 --- a/modules/cli/src/test/scala/cli/tests/ScalafmtTests.scala +++ b/modules/cli/src/test/scala/cli/tests/ScalafmtTests.scala @@ -9,6 +9,7 @@ import scala.build.tests.{TestInputs, TestLogger} import scala.cli.commands.fmt.{FmtOptions, FmtUtil} class ScalafmtTests extends TestUtil.ScalaCliSuite { + import ScalafmtTests.* private lazy val defaultScalafmtVersion = Constants.defaultScalafmtVersion test("readVersionFromFile with non-default scalafmt version") { @@ -35,8 +36,6 @@ class ScalafmtTests extends TestUtil.ScalaCliSuite { } test(s"check native launcher availability for scalafmt $defaultScalafmtVersion") { - final case class Asset(name: String) - final case class Release(tag_name: String, assets: List[Asset]) lazy val releaseCodec: JsonValueCodec[Release] = JsonCodecMaker.make val url = s"https://api.github.com/repos/scalameta/scalafmt/releases/tags/v$defaultScalafmtVersion" @@ -75,3 +74,8 @@ class ScalafmtTests extends TestUtil.ScalaCliSuite { } } } + +object ScalafmtTests { + private final case class Asset(name: String) + private final case class Release(tag_name: String, assets: List[Asset]) +} diff --git a/modules/core/src/main/scala/scala/build/internals/OsLibc.scala b/modules/core/src/main/scala/scala/build/internals/OsLibc.scala index d6811ed4b6..8c27ad31ce 100644 --- a/modules/core/src/main/scala/scala/build/internals/OsLibc.scala +++ b/modules/core/src/main/scala/scala/build/internals/OsLibc.scala @@ -56,7 +56,7 @@ object OsLibc { // FIXME These values should be the default ones in coursier-jvm lazy val jvmIndexOs: String = { - val default = JvmChannel.defaultOs + val default = JvmChannel.defaultOs() if (default == "linux" && isMusl.getOrElse(false)) "linux-musl" else default } diff --git a/modules/docs-tests/src/main/scala/sclicheck/sclicheck.scala b/modules/docs-tests/src/main/scala/sclicheck/sclicheck.scala index 5ed90756f8..3d62b04efd 100644 --- a/modules/docs-tests/src/main/scala/sclicheck/sclicheck.scala +++ b/modules/docs-tests/src/main/scala/sclicheck/sclicheck.scala @@ -350,7 +350,7 @@ def checkFile(file: os.Path, options: Options): Unit = if success then Green(commandName) else Red(commandName) println(s"$cmdLog ${cmd.log}") - println(logs.result.mkString("\n")) + println(logs.result().mkString("\n")) def pause(): Unit = println(s"After [${cmd.context}] using $out. Press ENTER key to continue...") diff --git a/modules/options/src/main/scala/scala/build/options/ConfigMonoid.scala b/modules/options/src/main/scala/scala/build/options/ConfigMonoid.scala index c07aa3f0a6..a2d5d16b2f 100644 --- a/modules/options/src/main/scala/scala/build/options/ConfigMonoid.scala +++ b/modules/options/src/main/scala/scala/build/options/ConfigMonoid.scala @@ -63,9 +63,6 @@ object ConfigMonoid: inline given derive[T](using m: Mirror.ProductOf[T]): ConfigMonoid[T] = inline m match case p: Mirror.ProductOf[T] => - new ConfigMonoid[T]: - def zero: T = - p.fromProduct(zeroTuple[m.MirroredElemTypes]) - - def orElse(main: T, defaults: T): T = - p.fromProduct(valueTuple[m.MirroredElemTypes, T](0, main, defaults)) + ConfigMonoidImpl[T](p.fromProduct(zeroTuple[m.MirroredElemTypes]))((main, defaults) => + p.fromProduct(valueTuple[m.MirroredElemTypes, T](0, main, defaults)) + ) From 6a6b282ac4a1bd99af91270467fd78b3d64ecfaf Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Fri, 10 Apr 2026 08:31:02 +0200 Subject: [PATCH 46/64] Avoid overriding CaseApp's constructor parameter by routing help customization through indirect overrides instead --- .../main/scala/scala/cli/commands/ScalaCommand.scala | 12 +++++++++++- .../scala/scala/cli/doc/GenerateReferenceDoc.scala | 4 ++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/modules/cli/src/main/scala/scala/cli/commands/ScalaCommand.scala b/modules/cli/src/main/scala/scala/cli/commands/ScalaCommand.scala index 4122c98ab4..94923786fb 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/ScalaCommand.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/ScalaCommand.scala @@ -1,5 +1,6 @@ package scala.cli.commands +import caseapp.core.Scala3Helpers.* import caseapp.core.app.Command import caseapp.core.complete.{Completer, CompletionItem} import caseapp.core.help.{Help, HelpFormat} @@ -296,7 +297,7 @@ abstract class ScalaCommand[T <: HasGlobalOptions](implicit myParser: Parser[T], override def helpFormat: HelpFormat = ScalaCliHelp.helpFormat - override val messages: Help[T] = + private val helpWithWarnings: Help[T] = if shouldExcludeInSip then inHelp.copy(helpMessage = Some(HelpMessage(WarningMessages.powerCommandUsedInSip( @@ -323,6 +324,15 @@ abstract class ScalaCommand[T <: HasGlobalOptions](implicit myParser: Parser[T], ) else inHelp + override def help: Help[T] = helpWithWarnings + + override lazy val finalHelp: Help[?] = + def withName[A](h: Help[A]): Help[A] = + if name == h.progName then h else h.withProgName(name) + if hasFullHelp then withName(help.withFullHelp) + else if hasHelp then withName(help.withHelp) + else withName(help) + /** @param options * command-specific [[T]] options * @return diff --git a/modules/generate-reference-doc/src/main/scala/scala/cli/doc/GenerateReferenceDoc.scala b/modules/generate-reference-doc/src/main/scala/scala/cli/doc/GenerateReferenceDoc.scala index 8c854f9ae4..0f4215e73e 100644 --- a/modules/generate-reference-doc/src/main/scala/scala/cli/doc/GenerateReferenceDoc.scala +++ b/modules/generate-reference-doc/src/main/scala/scala/cli/doc/GenerateReferenceDoc.scala @@ -314,7 +314,7 @@ object GenerateReferenceDoc extends CaseApp[InternalDocOptions] { if (command.names.tail.nonEmpty) b.section(command.names.map(_.mkString(" ")).tail.mkString("Aliases: `", "`, `", "`")) - for (desc <- command.messages.helpMessage.map(_.referenceDocDetailedMessage)) + for (desc <- command.help.helpMessage.map(_.referenceDocDetailedMessage)) b.section(desc) optionsForCommand(command) b.section("---") @@ -355,7 +355,7 @@ object GenerateReferenceDoc extends CaseApp[InternalDocOptions] { b.append(s"$headerPrefix## ${names.head}\n\n") if (names.tail.nonEmpty) b.append(names.tail.sorted.mkString("Aliases: `", "`, `", "`\n\n")) - for (desc <- c.messages.helpMessage.map(_.referenceDocDetailedMessage)) b.section(desc) + for (desc <- c.help.helpMessage.map(_.referenceDocDetailedMessage)) b.section(desc) if (origins.nonEmpty) { val links = origins.map { origin => From 81710906d2beb63cf29b169a1a9ab563c2d0bb2b Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Fri, 10 Apr 2026 08:41:17 +0200 Subject: [PATCH 47/64] Fix duplicate setting of `-deprecation` in the build --- build.mill | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/build.mill b/build.mill index 1952ca2f2d..efa7ac599e 100644 --- a/build.mill +++ b/build.mill @@ -314,9 +314,7 @@ trait BuildMacros extends ScalaCliCrossSbtModule } object test extends ScalaCliTests with ScalaCliScalafixModule { - override def scalacOptions: T[Seq[String]] = Task { - super.scalacOptions() ++ Seq("-deprecation") - } + override def scalacOptions: T[Seq[String]] = super.scalacOptions() def testNegativeCompilation(): Command[Unit] = Task.Command(exclusive = true) { val base = BuildCtx.workspaceRoot / "modules" / "build-macros" / "src" @@ -682,7 +680,7 @@ trait Config extends ScalaCliCrossSbtModule Seq(`specification-level`(crossScalaVersion)) override def mvnDeps: T[Seq[Dep]] = super.mvnDeps() ++ Seq(Deps.jsoniterCore) override def compileMvnDeps: T[Seq[Dep]] = super.compileMvnDeps() ++ Seq(Deps.jsoniterMacros) - override def scalacOptions: T[Seq[String]] = super.scalacOptions() ++ Seq("-deprecation") + override def scalacOptions: T[Seq[String]] = super.scalacOptions() } trait Options extends ScalaCliCrossSbtModule @@ -713,7 +711,7 @@ trait Options extends ScalaCliCrossSbtModule Task.Anon(super.repositoriesTask() ++ deps.customRepositories) object test extends ScalaCliTests with ScalaCliScalafixModule { - override def scalacOptions = super.scalacOptions() ++ Seq("-deprecation") + override def scalacOptions = super.scalacOptions() // uncomment below to debug tests in attach mode on 5005 port // def forkArgs = Task { // super.forkArgs() ++ Seq("-agentlib:jdwp=transport=dt_socket,server=n,address=localhost:5005,suspend=y") @@ -762,7 +760,7 @@ trait Build extends ScalaCliCrossSbtModule Task.Anon(super.repositoriesTask() ++ deps.customRepositories) object test extends ScalaCliTests with ScalaCliScalafixModule { - override def scalacOptions: T[Seq[String]] = super.scalacOptions() ++ Seq("-deprecation") + override def scalacOptions: T[Seq[String]] = super.scalacOptions() override def mvnDeps: T[Seq[Dep]] = super.mvnDeps() ++ Seq( Deps.pprint, Deps.slf4jNop From c50a1243f0c1e7cad0bb50fe4945cd6bfcd5eedd Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Fri, 10 Apr 2026 08:48:48 +0200 Subject: [PATCH 48/64] Remove unchecked cast in GraalVM substitution --- .../main/java/scala/cli/internal/PPrintStringPrefixSubst.java | 3 +-- .../scala/scala/cli/internal/PPrintStringPrefixHelper.scala | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/modules/cli/src/main/java/scala/cli/internal/PPrintStringPrefixSubst.java b/modules/cli/src/main/java/scala/cli/internal/PPrintStringPrefixSubst.java index 8e1f3c7dbd..5f3095ed66 100644 --- a/modules/cli/src/main/java/scala/cli/internal/PPrintStringPrefixSubst.java +++ b/modules/cli/src/main/java/scala/cli/internal/PPrintStringPrefixSubst.java @@ -10,8 +10,7 @@ final class PPrintStringPrefixSubst { @Substitute String apply(scala.collection.Iterable i) { - String name = (new PPrintStringPrefixHelper()).apply((scala.collection.Iterable) i); - return name; + return (new PPrintStringPrefixHelper()).apply(i); } } diff --git a/modules/cli/src/main/scala/scala/cli/internal/PPrintStringPrefixHelper.scala b/modules/cli/src/main/scala/scala/cli/internal/PPrintStringPrefixHelper.scala index c8f92ec3dd..4c373b261e 100644 --- a/modules/cli/src/main/scala/scala/cli/internal/PPrintStringPrefixHelper.scala +++ b/modules/cli/src/main/scala/scala/cli/internal/PPrintStringPrefixHelper.scala @@ -3,6 +3,6 @@ package scala.cli.internal // Remove once we can use https://github.com/com-lihaoyi/PPrint/pull/80 final class PPrintStringPrefixHelper { - def apply(i: Iterable[Object]): String = + def apply(i: Iterable[?]): String = i.collectionClassName } From b676d507d591c42e88eed00ceaf36799e3730fd1 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Fri, 10 Apr 2026 09:00:02 +0200 Subject: [PATCH 49/64] Remove legacy GraalVM substitution workaround for `pprint` --- .../cli/internal/PPrintStringPrefixSubst.java | 16 ---------------- .../cli/internal/PPrintStringPrefixHelper.scala | 8 -------- 2 files changed, 24 deletions(-) delete mode 100644 modules/cli/src/main/java/scala/cli/internal/PPrintStringPrefixSubst.java delete mode 100644 modules/cli/src/main/scala/scala/cli/internal/PPrintStringPrefixHelper.scala diff --git a/modules/cli/src/main/java/scala/cli/internal/PPrintStringPrefixSubst.java b/modules/cli/src/main/java/scala/cli/internal/PPrintStringPrefixSubst.java deleted file mode 100644 index 5f3095ed66..0000000000 --- a/modules/cli/src/main/java/scala/cli/internal/PPrintStringPrefixSubst.java +++ /dev/null @@ -1,16 +0,0 @@ -package scala.cli.internal; - -import com.oracle.svm.core.annotate.Substitute; -import com.oracle.svm.core.annotate.TargetClass; - -// Remove once we can use https://github.com/com-lihaoyi/PPrint/pull/80 - -@TargetClass(className = "pprint.StringPrefix$") -final class PPrintStringPrefixSubst { - - @Substitute - String apply(scala.collection.Iterable i) { - return (new PPrintStringPrefixHelper()).apply(i); - } - -} diff --git a/modules/cli/src/main/scala/scala/cli/internal/PPrintStringPrefixHelper.scala b/modules/cli/src/main/scala/scala/cli/internal/PPrintStringPrefixHelper.scala deleted file mode 100644 index 4c373b261e..0000000000 --- a/modules/cli/src/main/scala/scala/cli/internal/PPrintStringPrefixHelper.scala +++ /dev/null @@ -1,8 +0,0 @@ -package scala.cli.internal - -// Remove once we can use https://github.com/com-lihaoyi/PPrint/pull/80 - -final class PPrintStringPrefixHelper { - def apply(i: Iterable[?]): String = - i.collectionClassName -} From 9541df414a0857f898f950411795f8cd4f05fe38 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Sat, 11 Apr 2026 19:25:36 +0200 Subject: [PATCH 50/64] Merge pull request #4221 from Gedochao/fix/flaky-cross-package Make each packaged native image use its own subdirectory under `nativeImageWorkDir` when cross-packaging --- .../scala/scala/cli/commands/package0/Package.scala | 11 ++++++++++- .../cli/integration/PackageTestDefinitions.scala | 5 ++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/modules/cli/src/main/scala/scala/cli/commands/package0/Package.scala b/modules/cli/src/main/scala/scala/cli/commands/package0/Package.scala index 0ddda4e82c..ab806b53f1 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/package0/Package.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/package0/Package.scala @@ -191,6 +191,15 @@ object Package extends ScalaCommand[PackageOptions] with BuildCommandHelpers { else name + suffix } + /** GraalVM leaves many temp files under the work dir; a shared dir breaks `--cross` packaging. */ + private def nativeImageWorkDirForArtifact(baseDir: os.Path, dest: os.Path): os.Path = { + def stripSuffixIgnoreCase(s: String, suffix: String): String = + if s.toLowerCase.endsWith(suffix.toLowerCase) then s.substring(0, s.length - suffix.length) + else s + val stem = stripSuffixIgnoreCase(dest.last, ".exe") + baseDir / (if stem.nonEmpty then stem else "native-image") + } + private def doPackageCrossBuilds( logger: Logger, outputOpt: Option[String], @@ -501,7 +510,7 @@ object Package extends ScalaCommand[PackageOptions] with BuildCommandHelpers { builds, value(mainClass), destPath, - builds.head.inputs.nativeImageWorkDir, + nativeImageWorkDirForArtifact(builds.head.inputs.nativeImageWorkDir, destPath), extraArgs, logger ) diff --git a/modules/integration/src/test/scala/scala/cli/integration/PackageTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/PackageTestDefinitions.scala index 97c20ec286..be33e5ad51 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/PackageTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/PackageTestDefinitions.scala @@ -1504,7 +1504,10 @@ abstract class PackageTestDefinitions extends ScalaCliSuite with TestScalaVersio Seq(actualScalaVersion, Constants.scala213, Constants.scala212) val numberOfBuilds = crossScalaVersions.size test(s"package ($packageDescription, --cross) produces $numberOfBuilds artifacts") { - TestUtil.retryOnCi() { + TestUtil.retryOnCi( + maxAttempts = if packageDescription == "--native-image" then 5 else 3, + waitDuration = if packageDescription == "--native-image" then 15.seconds else 5.seconds + ) { val crossDirective = s"//> using scala ${crossScalaVersions.mkString(" ")}" val mainClass = "TestScopeMain" From 700ee8653cf00b68219fb301b0241d564dbb5fd9 Mon Sep 17 00:00:00 2001 From: Zia Ur Rehman <4042217+zrhmn@users.noreply.github.com> Date: Mon, 13 Apr 2026 03:09:58 -0400 Subject: [PATCH 51/64] Add 'packaging.graalvmJvmId' directive (#4223) --- .../tests/PackagingUsingDirectiveTests.scala | 17 +++++++++++++++++ .../preprocessing/directives/Packaging.scala | 5 +++++ website/docs/reference/directives.md | 4 ++++ 3 files changed, 26 insertions(+) diff --git a/modules/build/src/test/scala/scala/build/tests/PackagingUsingDirectiveTests.scala b/modules/build/src/test/scala/scala/build/tests/PackagingUsingDirectiveTests.scala index 9b0656cebf..b86e0f02f3 100644 --- a/modules/build/src/test/scala/scala/build/tests/PackagingUsingDirectiveTests.scala +++ b/modules/build/src/test/scala/scala/build/tests/PackagingUsingDirectiveTests.scala @@ -34,6 +34,23 @@ class PackagingUsingDirectiveTests extends TestUtil.ScalaCliBuildSuite { } } + test("graalvm packaging") { + val inputs = TestInputs( + os.rel / "p.sc" -> + """//> using packaging.packageType graalvm + |//> using packaging.graalvmJvmId graalvm-community:23.0.2 + |//> using packaging.graalvmArgs --no-fallback + | + |def foo() = println("hello foo") + |""".stripMargin + ) + inputs.withLoadedBuild(buildOptions, buildThreads, bloopConfig) { (_, _, maybeBuild) => + val nativeImageOpt = maybeBuild.options.notForBloopOptions.packageOptions.nativeImageOptions + expect(nativeImageOpt.jvmId == "graalvm-community:23.0.2") + expect(nativeImageOpt.graalvmArgs.exists(_.value == "--no-fallback")) + } + } + test("output") { val output = "foo" val inputs = TestInputs( diff --git a/modules/directives/src/main/scala/scala/build/preprocessing/directives/Packaging.scala b/modules/directives/src/main/scala/scala/build/preprocessing/directives/Packaging.scala index 77d7f3775c..7eb92e605e 100644 --- a/modules/directives/src/main/scala/scala/build/preprocessing/directives/Packaging.scala +++ b/modules/directives/src/main/scala/scala/build/preprocessing/directives/Packaging.scala @@ -24,6 +24,7 @@ import scala.util.Try @DirectiveExamples("//> using packaging.output foo") @DirectiveExamples("//> using packaging.provided org.apache.spark::spark-sql") @DirectiveExamples("//> using packaging.graalvmArgs --no-fallback") +@DirectiveExamples("//> using packaging.graalvmJvmId graalvm-community:23.0.2") @DirectiveExamples("//> using packaging.dockerFrom openjdk:11") @DirectiveExamples("//> using packaging.dockerImageTag 1.0.0") @DirectiveExamples("//> using packaging.dockerImageRegistry virtuslab") @@ -54,6 +55,8 @@ import scala.util.Try | |`//> using packaging.graalvmArgs` _args_ | + |`//> using packaging.graalvmJvmId` _graalvm-jvm-id_ + | |`//> using packaging.dockerFrom` _base-docker-image_ | |`//> using packaging.dockerImageTag` _image-tag_ @@ -76,6 +79,7 @@ final case class Packaging( output: Option[String] = None, provided: List[Positioned[String]] = Nil, graalvmArgs: List[Positioned[String]] = Nil, + graalvmJvmId: Option[String] = None, dockerFrom: Option[String] = None, dockerImageTag: Option[String] = None, dockerImageRegistry: Option[String] = None, @@ -158,6 +162,7 @@ final case class Packaging( extraDirectories = extraDirectories ), nativeImageOptions = NativeImageOptions( + graalvmJvmId = graalvmJvmId, graalvmArgs = graalvmArgs ) ) diff --git a/website/docs/reference/directives.md b/website/docs/reference/directives.md index 8bd2d05a20..a88985021d 100644 --- a/website/docs/reference/directives.md +++ b/website/docs/reference/directives.md @@ -290,6 +290,8 @@ Set parameters for packaging `//> using packaging.graalvmArgs` _args_ +`//> using packaging.graalvmJvmId` _graalvm-jvm-id_ + `//> using packaging.dockerFrom` _base-docker-image_ `//> using packaging.dockerImageTag` _image-tag_ @@ -314,6 +316,8 @@ Set parameters for packaging `//> using packaging.graalvmArgs --no-fallback` +`//> using packaging.graalvmJvmId graalvm-community:23.0.2` + `//> using packaging.dockerFrom openjdk:11` `//> using packaging.dockerImageTag 1.0.0` From e1081c722d1d922d34df0a087c399523db4ae71e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 08:08:56 +0200 Subject: [PATCH 52/64] Bump the npm-dependencies group in /website with 6 updates (#4226) Bumps the npm-dependencies group in /website with 6 updates: | Package | From | To | | --- | --- | --- | | [@docusaurus/core](https://github.com/facebook/docusaurus/tree/HEAD/packages/docusaurus) | `3.9.2` | `3.10.0` | | [@docusaurus/plugin-content-docs](https://github.com/facebook/docusaurus/tree/HEAD/packages/docusaurus-plugin-content-docs) | `3.9.2` | `3.10.0` | | [@docusaurus/preset-classic](https://github.com/facebook/docusaurus/tree/HEAD/packages/docusaurus-preset-classic) | `3.9.2` | `3.10.0` | | [@docusaurus/theme-common](https://github.com/facebook/docusaurus/tree/HEAD/packages/docusaurus-theme-common) | `3.9.2` | `3.10.0` | | [react](https://github.com/facebook/react/tree/HEAD/packages/react) | `19.2.4` | `19.2.5` | | [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) | `19.2.4` | `19.2.5` | Updates `@docusaurus/core` from 3.9.2 to 3.10.0 - [Release notes](https://github.com/facebook/docusaurus/releases) - [Changelog](https://github.com/facebook/docusaurus/blob/main/CHANGELOG.md) - [Commits](https://github.com/facebook/docusaurus/commits/v3.10.0/packages/docusaurus) Updates `@docusaurus/plugin-content-docs` from 3.9.2 to 3.10.0 - [Release notes](https://github.com/facebook/docusaurus/releases) - [Changelog](https://github.com/facebook/docusaurus/blob/main/CHANGELOG.md) - [Commits](https://github.com/facebook/docusaurus/commits/v3.10.0/packages/docusaurus-plugin-content-docs) Updates `@docusaurus/preset-classic` from 3.9.2 to 3.10.0 - [Release notes](https://github.com/facebook/docusaurus/releases) - [Changelog](https://github.com/facebook/docusaurus/blob/main/CHANGELOG.md) - [Commits](https://github.com/facebook/docusaurus/commits/v3.10.0/packages/docusaurus-preset-classic) Updates `@docusaurus/theme-common` from 3.9.2 to 3.10.0 - [Release notes](https://github.com/facebook/docusaurus/releases) - [Changelog](https://github.com/facebook/docusaurus/blob/main/CHANGELOG.md) - [Commits](https://github.com/facebook/docusaurus/commits/v3.10.0/packages/docusaurus-theme-common) Updates `react` from 19.2.4 to 19.2.5 - [Release notes](https://github.com/facebook/react/releases) - [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md) - [Commits](https://github.com/facebook/react/commits/v19.2.5/packages/react) Updates `react-dom` from 19.2.4 to 19.2.5 - [Release notes](https://github.com/facebook/react/releases) - [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md) - [Commits](https://github.com/facebook/react/commits/v19.2.5/packages/react-dom) --- updated-dependencies: - dependency-name: "@docusaurus/core" dependency-version: 3.10.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-dependencies - dependency-name: "@docusaurus/plugin-content-docs" dependency-version: 3.10.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-dependencies - dependency-name: "@docusaurus/preset-classic" dependency-version: 3.10.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-dependencies - dependency-name: "@docusaurus/theme-common" dependency-version: 3.10.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-dependencies - dependency-name: react dependency-version: 19.2.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: npm-dependencies - dependency-name: react-dom dependency-version: 19.2.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: npm-dependencies ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- website/package.json | 10 +- website/yarn.lock | 694 +++++++++++++++++++------------------------ 2 files changed, 312 insertions(+), 392 deletions(-) diff --git a/website/package.json b/website/package.json index 07834eb4b6..62fa85aa4d 100644 --- a/website/package.json +++ b/website/package.json @@ -15,17 +15,17 @@ }, "dependencies": { "@algolia/client-search": "^5.50.1", - "@docusaurus/core": "^3.9.2", - "@docusaurus/plugin-content-docs": "^3.9.2", - "@docusaurus/preset-classic": "^3.9.2", + "@docusaurus/core": "^3.10.0", + "@docusaurus/plugin-content-docs": "^3.10.0", + "@docusaurus/preset-classic": "^3.10.0", "@docusaurus/theme-common": "^3.9.2", "@easyops-cn/docusaurus-search-local": "^0.55.1", "@mdx-js/react": "^3.1.1", "@types/react": "^19.2.14", "clsx": "^2.1.1", "docusaurus-plugin-sass": "^0.2.6", - "react": "^19.2.4", - "react-dom": "^19.2.4", + "react": "^19.2.5", + "react-dom": "^19.2.5", "react-loadable": "^5.5.0", "react-player": "^3.4.0", "sass": "^1.99.0", diff --git a/website/yarn.lock b/website/yarn.lock index bc7ccec590..ad850e281f 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -2,41 +2,6 @@ # yarn lockfile v1 -"@ai-sdk/gateway@2.0.21": - version "2.0.21" - resolved "https://registry.yarnpkg.com/@ai-sdk/gateway/-/gateway-2.0.21.tgz#17eb0620352bc81e7d87ee6a8705148c9149029f" - integrity sha512-BwV7DU/lAm3Xn6iyyvZdWgVxgLu3SNXzl5y57gMvkW4nGhAOV5269IrJzQwGt03bb107sa6H6uJwWxc77zXoGA== - dependencies: - "@ai-sdk/provider" "2.0.0" - "@ai-sdk/provider-utils" "3.0.19" - "@vercel/oidc" "3.0.5" - -"@ai-sdk/provider-utils@3.0.19": - version "3.0.19" - resolved "https://registry.yarnpkg.com/@ai-sdk/provider-utils/-/provider-utils-3.0.19.tgz#065e4ffe287ec536b882fdcdff0bd38c250a4873" - integrity sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA== - dependencies: - "@ai-sdk/provider" "2.0.0" - "@standard-schema/spec" "^1.0.0" - eventsource-parser "^3.0.6" - -"@ai-sdk/provider@2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@ai-sdk/provider/-/provider-2.0.0.tgz#b853c739d523b33675bc74b6c506b2c690bc602b" - integrity sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA== - dependencies: - json-schema "^0.4.0" - -"@ai-sdk/react@^2.0.30": - version "2.0.115" - resolved "https://registry.yarnpkg.com/@ai-sdk/react/-/react-2.0.115.tgz#ae9aaaa60804a1000951217becac9f0521285f31" - integrity sha512-Etu7gWSEi2dmXss1PoR5CAZGwGShXsF9+Pon1eRO6EmatjYaBMhq1CfHPyYhGzWrint8jJIK2VaAhiMef29qZw== - dependencies: - "@ai-sdk/provider-utils" "3.0.19" - ai "5.0.113" - swr "^2.2.5" - throttleit "2.1.0" - "@algolia/abtesting@1.12.0": version "1.12.0" resolved "https://registry.yarnpkg.com/@algolia/abtesting/-/abtesting-1.12.0.tgz#fad11266e85f33acf1a37961f4110459491428f5" @@ -55,6 +20,14 @@ "@algolia/autocomplete-plugin-algolia-insights" "1.19.2" "@algolia/autocomplete-shared" "1.19.2" +"@algolia/autocomplete-core@^1.19.2": + version "1.19.8" + resolved "https://registry.yarnpkg.com/@algolia/autocomplete-core/-/autocomplete-core-1.19.8.tgz#7c84c771d28643fb00d09026c05013fb97aeea23" + integrity sha512-3YEorYg44niXcm7gkft3nXYItHd44e8tmh4D33CTszPgP0QWkaLEaFywiNyJBo7UL/mqObA/G9RYuU7R8tN1IA== + dependencies: + "@algolia/autocomplete-plugin-algolia-insights" "1.19.8" + "@algolia/autocomplete-shared" "1.19.8" + "@algolia/autocomplete-plugin-algolia-insights@1.19.2": version "1.19.2" resolved "https://registry.yarnpkg.com/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.19.2.tgz#3584b625b9317e333d1ae43664d02358e175c52d" @@ -62,11 +35,23 @@ dependencies: "@algolia/autocomplete-shared" "1.19.2" +"@algolia/autocomplete-plugin-algolia-insights@1.19.8": + version "1.19.8" + resolved "https://registry.yarnpkg.com/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.19.8.tgz#f60d21edbe2a42e6d4e2215430733e3f51641471" + integrity sha512-ZvJWO8ZZJDpc1LNM2TTBdmQsZBLMR4rU5iNR2OYvEeFBiaf/0ESnRSSLQbryarJY4SVxtoz6A2ZtDMNM+iQEAA== + dependencies: + "@algolia/autocomplete-shared" "1.19.8" + "@algolia/autocomplete-shared@1.19.2": version "1.19.2" resolved "https://registry.yarnpkg.com/@algolia/autocomplete-shared/-/autocomplete-shared-1.19.2.tgz#c0b7b8dc30a5c65b70501640e62b009535e4578f" integrity sha512-jEazxZTVD2nLrC+wYlVHQgpBoBB5KPStrJxLzsIFl6Kqd1AlG9sIAGl39V5tECLpIQzB3Qa2T6ZPJ1ChkwMK/w== +"@algolia/autocomplete-shared@1.19.8": + version "1.19.8" + resolved "https://registry.yarnpkg.com/@algolia/autocomplete-shared/-/autocomplete-shared-1.19.8.tgz#5d723d8bdb448efbb1b0e1c7ff94cc18e5b1dc0e" + integrity sha512-h5hf2t8ejF6vlOgvLaZzQbWs5SyH2z4PAWygNAvvD/2RI29hdQ54ldUGwqVuj9Srs+n8XUKTPUqb7fvhBhQrnQ== + "@algolia/client-abtesting@5.46.0": version "5.46.0" resolved "https://registry.yarnpkg.com/@algolia/client-abtesting/-/client-abtesting-5.46.0.tgz#488eadc7220a53f585077010fc2093d0c58dc37e" @@ -1073,13 +1058,6 @@ "@babel/plugin-transform-modules-commonjs" "^7.27.1" "@babel/plugin-transform-typescript" "^7.28.5" -"@babel/runtime-corejs3@^7.25.9": - version "7.28.4" - resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.28.4.tgz#c25be39c7997ce2f130d70b9baecb8ed94df93fa" - integrity sha512-h7iEYiW4HebClDEhtvFObtPmIvrd1SSfpI9EhOeKk4CtIK/ngBWFpuhCzhdmRKtg71ylcue+9I6dv54XYO1epQ== - dependencies: - core-js-pure "^3.43.0" - "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.25.9": version "7.28.4" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.4.tgz#a70226016fabe25c5783b2f22d3e1c9bc5ca3326" @@ -1504,34 +1482,29 @@ resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw== -"@docsearch/core@4.3.1": - version "4.3.1" - resolved "https://registry.yarnpkg.com/@docsearch/core/-/core-4.3.1.tgz#88a97a6fe4d4025269b6dee8b9d070b76758ad82" - integrity sha512-ktVbkePE+2h9RwqCUMbWXOoebFyDOxHqImAqfs+lC8yOU+XwEW4jgvHGJK079deTeHtdhUNj0PXHSnhJINvHzQ== +"@docsearch/core@4.6.2": + version "4.6.2" + resolved "https://registry.yarnpkg.com/@docsearch/core/-/core-4.6.2.tgz#0a6fdc13b1eb12153cb19316f911479b67f7bd58" + integrity sha512-/S0e6Dj7Zcm8m9Rru49YEX49dhU11be68c+S/BCyN8zQsTTgkKzXlhRbVL5mV6lOLC2+ZRRryaTdcm070Ug2oA== -"@docsearch/css@4.3.2": - version "4.3.2" - resolved "https://registry.yarnpkg.com/@docsearch/css/-/css-4.3.2.tgz#d47d25336c9516b419245fa74e8dd5ae84a17492" - integrity sha512-K3Yhay9MgkBjJJ0WEL5MxnACModX9xuNt3UlQQkDEDZJZ0+aeWKtOkxHNndMRkMBnHdYvQjxkm6mdlneOtU1IQ== +"@docsearch/css@4.6.2": + version "4.6.2" + resolved "https://registry.yarnpkg.com/@docsearch/css/-/css-4.6.2.tgz#986776619dccbf798176c75e858cc22f5e710bb4" + integrity sha512-fH/cn8BjEEdM2nJdjNMHIvOVYupG6AIDtFVDgIZrNzdCSj4KXr9kd+hsehqsNGYjpUjObeKYKvgy/IwCb1jZYQ== -"@docsearch/react@^3.9.0 || ^4.1.0": - version "4.3.2" - resolved "https://registry.yarnpkg.com/@docsearch/react/-/react-4.3.2.tgz#450b8341cb5cca03737a00075d4dfd3a904a3e3e" - integrity sha512-74SFD6WluwvgsOPqifYOviEEVwDxslxfhakTlra+JviaNcs7KK/rjsPj89kVEoQc9FUxRkAofaJnHIR7pb4TSQ== +"@docsearch/react@^3.9.0 || ^4.3.2": + version "4.6.2" + resolved "https://registry.yarnpkg.com/@docsearch/react/-/react-4.6.2.tgz#e6c65fb87fb943eefaa936debe6d31bb51b25056" + integrity sha512-/BbtGFtqVOGwZx0dw/UfhN/0/DmMQYnulY4iv0tPRhC2JCXv0ka/+izwt3Jzo1ZxXS/2eMvv9zHsBJOK1I9f/w== dependencies: - "@ai-sdk/react" "^2.0.30" "@algolia/autocomplete-core" "1.19.2" - "@docsearch/core" "4.3.1" - "@docsearch/css" "4.3.2" - ai "^5.0.30" - algoliasearch "^5.28.0" - marked "^16.3.0" - zod "^4.1.8" - -"@docusaurus/babel@3.9.2": - version "3.9.2" - resolved "https://registry.yarnpkg.com/@docusaurus/babel/-/babel-3.9.2.tgz#f956c638baeccf2040e482c71a742bc7e35fdb22" - integrity sha512-GEANdi/SgER+L7Japs25YiGil/AUDnFFHaCGPBbundxoWtCkA2lmy7/tFmgED4y1htAy6Oi4wkJEQdGssnw9MA== + "@docsearch/core" "4.6.2" + "@docsearch/css" "4.6.2" + +"@docusaurus/babel@3.10.0": + version "3.10.0" + resolved "https://registry.yarnpkg.com/@docusaurus/babel/-/babel-3.10.0.tgz#819819f107233dfcf50b59cd51158f23fb04878a" + integrity sha512-mqCJhCZNZUDg0zgDEaPTM4DnRsisa24HdqTy/qn/MQlbwhTb4WVaZg6ZyX6yIVKqTz8fS1hBMgM+98z+BeJJDg== dependencies: "@babel/core" "^7.25.9" "@babel/generator" "^7.25.9" @@ -1541,25 +1514,24 @@ "@babel/preset-react" "^7.25.9" "@babel/preset-typescript" "^7.25.9" "@babel/runtime" "^7.25.9" - "@babel/runtime-corejs3" "^7.25.9" "@babel/traverse" "^7.25.9" - "@docusaurus/logger" "3.9.2" - "@docusaurus/utils" "3.9.2" + "@docusaurus/logger" "3.10.0" + "@docusaurus/utils" "3.10.0" babel-plugin-dynamic-import-node "^2.3.3" fs-extra "^11.1.1" tslib "^2.6.0" -"@docusaurus/bundler@3.9.2": - version "3.9.2" - resolved "https://registry.yarnpkg.com/@docusaurus/bundler/-/bundler-3.9.2.tgz#0ca82cda4acf13a493e3f66061aea351e9d356cf" - integrity sha512-ZOVi6GYgTcsZcUzjblpzk3wH1Fya2VNpd5jtHoCCFcJlMQ1EYXZetfAnRHLcyiFeBABaI1ltTYbOBtH/gahGVA== +"@docusaurus/bundler@3.10.0": + version "3.10.0" + resolved "https://registry.yarnpkg.com/@docusaurus/bundler/-/bundler-3.10.0.tgz#878c4c46bfa3434671ea37a43da184238a6aae26" + integrity sha512-iONUGZGgp+lAkw/cJZH6irONcF4p8+278IsdRlq8lYhxGjkoNUs0w7F4gVXBYSNChq5KG5/JleTSsdJySShxow== dependencies: "@babel/core" "^7.25.9" - "@docusaurus/babel" "3.9.2" - "@docusaurus/cssnano-preset" "3.9.2" - "@docusaurus/logger" "3.9.2" - "@docusaurus/types" "3.9.2" - "@docusaurus/utils" "3.9.2" + "@docusaurus/babel" "3.10.0" + "@docusaurus/cssnano-preset" "3.10.0" + "@docusaurus/logger" "3.10.0" + "@docusaurus/types" "3.10.0" + "@docusaurus/utils" "3.10.0" babel-loader "^9.2.1" clean-css "^5.3.3" copy-webpack-plugin "^11.0.0" @@ -1579,18 +1551,18 @@ webpack "^5.95.0" webpackbar "^6.0.1" -"@docusaurus/core@3.9.2", "@docusaurus/core@^3.9.2": - version "3.9.2" - resolved "https://registry.yarnpkg.com/@docusaurus/core/-/core-3.9.2.tgz#cc970f29b85a8926d63c84f8cffdcda43ed266ff" - integrity sha512-HbjwKeC+pHUFBfLMNzuSjqFE/58+rLVKmOU3lxQrpsxLBOGosYco/Q0GduBb0/jEMRiyEqjNT/01rRdOMWq5pw== - dependencies: - "@docusaurus/babel" "3.9.2" - "@docusaurus/bundler" "3.9.2" - "@docusaurus/logger" "3.9.2" - "@docusaurus/mdx-loader" "3.9.2" - "@docusaurus/utils" "3.9.2" - "@docusaurus/utils-common" "3.9.2" - "@docusaurus/utils-validation" "3.9.2" +"@docusaurus/core@3.10.0", "@docusaurus/core@^3.10.0": + version "3.10.0" + resolved "https://registry.yarnpkg.com/@docusaurus/core/-/core-3.10.0.tgz#642e71a0209d62c3f5ef275ed9d74a881f40df39" + integrity sha512-mgLdQsO8xppnQZc3LPi+Mf+PkPeyxJeIx11AXAq/14fsaMefInQiMEZUUmrc7J+956G/f7MwE7tn8KZgi3iRcA== + dependencies: + "@docusaurus/babel" "3.10.0" + "@docusaurus/bundler" "3.10.0" + "@docusaurus/logger" "3.10.0" + "@docusaurus/mdx-loader" "3.10.0" + "@docusaurus/utils" "3.10.0" + "@docusaurus/utils-common" "3.10.0" + "@docusaurus/utils-validation" "3.10.0" boxen "^6.2.1" chalk "^4.1.2" chokidar "^3.5.3" @@ -1602,7 +1574,7 @@ escape-html "^1.0.3" eta "^2.2.0" eval "^0.1.8" - execa "5.1.1" + execa "^5.1.1" fs-extra "^11.1.1" html-tags "^3.3.1" html-webpack-plugin "^5.6.0" @@ -1613,12 +1585,12 @@ prompts "^2.4.2" react-helmet-async "npm:@slorber/react-helmet-async@1.3.0" react-loadable "npm:@docusaurus/react-loadable@6.0.0" - react-loadable-ssr-addon-v5-slorber "^1.0.1" + react-loadable-ssr-addon-v5-slorber "^1.0.3" react-router "^5.3.4" react-router-config "^5.1.1" react-router-dom "^5.3.4" semver "^7.5.4" - serve-handler "^6.1.6" + serve-handler "^6.1.7" tinypool "^1.0.2" tslib "^2.6.0" update-notifier "^6.0.2" @@ -1627,32 +1599,32 @@ webpack-dev-server "^5.2.2" webpack-merge "^6.0.1" -"@docusaurus/cssnano-preset@3.9.2": - version "3.9.2" - resolved "https://registry.yarnpkg.com/@docusaurus/cssnano-preset/-/cssnano-preset-3.9.2.tgz#523aab65349db3c51a77f2489048d28527759428" - integrity sha512-8gBKup94aGttRduABsj7bpPFTX7kbwu+xh3K9NMCF5K4bWBqTFYW+REKHF6iBVDHRJ4grZdIPbvkiHd/XNKRMQ== +"@docusaurus/cssnano-preset@3.10.0": + version "3.10.0" + resolved "https://registry.yarnpkg.com/@docusaurus/cssnano-preset/-/cssnano-preset-3.10.0.tgz#be1b435c33df09d743473d3fadda67b4568dfae3" + integrity sha512-qzSshTO1DB3TYW+dPUal5KHM7XPc5YQfzF3Kdb2NDACJUyGbNcFtw3tGkCJlYwhNCRKbZcmwraKUS1i5dcHdGg== dependencies: cssnano-preset-advanced "^6.1.2" postcss "^8.5.4" postcss-sort-media-queries "^5.2.0" tslib "^2.6.0" -"@docusaurus/logger@3.9.2": - version "3.9.2" - resolved "https://registry.yarnpkg.com/@docusaurus/logger/-/logger-3.9.2.tgz#6ec6364b90f5a618a438cc9fd01ac7376869f92a" - integrity sha512-/SVCc57ByARzGSU60c50rMyQlBuMIJCjcsJlkphxY6B0GV4UH3tcA1994N8fFfbJ9kX3jIBe/xg3XP5qBtGDbA== +"@docusaurus/logger@3.10.0": + version "3.10.0" + resolved "https://registry.yarnpkg.com/@docusaurus/logger/-/logger-3.10.0.tgz#2bacbd004dd78e3da926dbe8f6fa9a930856575d" + integrity sha512-9jrZzFuBH1LDRlZ7cznAhCLmAZ3HSDqgwdrSSZdGHq9SPUOQgXXu8mnxe2ZRB9NS1PCpMTIOVUqDtZPIhMafZg== dependencies: chalk "^4.1.2" tslib "^2.6.0" -"@docusaurus/mdx-loader@3.9.2": - version "3.9.2" - resolved "https://registry.yarnpkg.com/@docusaurus/mdx-loader/-/mdx-loader-3.9.2.tgz#78d238de6c6203fa811cc2a7e90b9b79e111408c" - integrity sha512-wiYoGwF9gdd6rev62xDU8AAM8JuLI/hlwOtCzMmYcspEkzecKrP8J8X+KpYnTlACBUUtXNJpSoCwFWJhLRevzQ== +"@docusaurus/mdx-loader@3.10.0": + version "3.10.0" + resolved "https://registry.yarnpkg.com/@docusaurus/mdx-loader/-/mdx-loader-3.10.0.tgz#1d4b050d751389ecf38dee48bcb61e53df8ffb82" + integrity sha512-mQQV97080AH4PYNs087l202NMDqRopZA4mg5W76ZZyTFrmWhJ3mHg+8A+drJVENxw5/Q+wHMHLgsx+9z1nEs0A== dependencies: - "@docusaurus/logger" "3.9.2" - "@docusaurus/utils" "3.9.2" - "@docusaurus/utils-validation" "3.9.2" + "@docusaurus/logger" "3.10.0" + "@docusaurus/utils" "3.10.0" + "@docusaurus/utils-validation" "3.10.0" "@mdx-js/mdx" "^3.0.0" "@slorber/remark-comment" "^1.0.0" escape-html "^1.0.3" @@ -1675,12 +1647,12 @@ vfile "^6.0.1" webpack "^5.88.1" -"@docusaurus/module-type-aliases@3.9.2": - version "3.9.2" - resolved "https://registry.yarnpkg.com/@docusaurus/module-type-aliases/-/module-type-aliases-3.9.2.tgz#993c7cb0114363dea5ef6855e989b3ad4b843a34" - integrity sha512-8qVe2QA9hVLzvnxP46ysuofJUIc/yYQ82tvA/rBTrnpXtCjNSFLxEZfd5U8cYZuJIVlkPxamsIgwd5tGZXfvew== +"@docusaurus/module-type-aliases@3.10.0": + version "3.10.0" + resolved "https://registry.yarnpkg.com/@docusaurus/module-type-aliases/-/module-type-aliases-3.10.0.tgz#749928f104d563f11f046bf0c9ab6489a470c7c8" + integrity sha512-/1O0Zg8w3DFrYX/I6Fbss7OJrtZw1QoyjDhegiFNHVi9A9Y0gQ3jUAytVxF6ywpAWpLyLxch8nN8H/V3XfzdJQ== dependencies: - "@docusaurus/types" "3.9.2" + "@docusaurus/types" "3.10.0" "@types/history" "^4.7.11" "@types/react" "*" "@types/react-router-config" "*" @@ -1688,20 +1660,21 @@ react-helmet-async "npm:@slorber/react-helmet-async@1.3.0" react-loadable "npm:@docusaurus/react-loadable@6.0.0" -"@docusaurus/plugin-content-blog@3.9.2": - version "3.9.2" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.9.2.tgz#d5ce51eb7757bdab0515e2dd26a793ed4e119df9" - integrity sha512-3I2HXy3L1QcjLJLGAoTvoBnpOwa6DPUa3Q0dMK19UTY9mhPkKQg/DYhAGTiBUKcTR0f08iw7kLPqOhIgdV3eVQ== - dependencies: - "@docusaurus/core" "3.9.2" - "@docusaurus/logger" "3.9.2" - "@docusaurus/mdx-loader" "3.9.2" - "@docusaurus/theme-common" "3.9.2" - "@docusaurus/types" "3.9.2" - "@docusaurus/utils" "3.9.2" - "@docusaurus/utils-common" "3.9.2" - "@docusaurus/utils-validation" "3.9.2" +"@docusaurus/plugin-content-blog@3.10.0": + version "3.10.0" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.10.0.tgz#10095291b637440847854ecb2c8afcd8746debd7" + integrity sha512-RuTz68DhB7CL96QO5UsFbciD7GPYq6QV+YMfF9V0+N4ZgLhJIBgpVAr8GobrKF6NRe5cyWWETU5z5T834piG9g== + dependencies: + "@docusaurus/core" "3.10.0" + "@docusaurus/logger" "3.10.0" + "@docusaurus/mdx-loader" "3.10.0" + "@docusaurus/theme-common" "3.10.0" + "@docusaurus/types" "3.10.0" + "@docusaurus/utils" "3.10.0" + "@docusaurus/utils-common" "3.10.0" + "@docusaurus/utils-validation" "3.10.0" cheerio "1.0.0-rc.12" + combine-promises "^1.1.0" feed "^4.2.2" fs-extra "^11.1.1" lodash "^4.17.21" @@ -1712,20 +1685,20 @@ utility-types "^3.10.0" webpack "^5.88.1" -"@docusaurus/plugin-content-docs@3.9.2", "@docusaurus/plugin-content-docs@^2 || ^3", "@docusaurus/plugin-content-docs@^3.9.2": - version "3.9.2" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.9.2.tgz#cd8f2d1c06e53c3fa3d24bdfcb48d237bf2d6b2e" - integrity sha512-C5wZsGuKTY8jEYsqdxhhFOe1ZDjH0uIYJ9T/jebHwkyxqnr4wW0jTkB72OMqNjsoQRcb0JN3PcSeTwFlVgzCZg== - dependencies: - "@docusaurus/core" "3.9.2" - "@docusaurus/logger" "3.9.2" - "@docusaurus/mdx-loader" "3.9.2" - "@docusaurus/module-type-aliases" "3.9.2" - "@docusaurus/theme-common" "3.9.2" - "@docusaurus/types" "3.9.2" - "@docusaurus/utils" "3.9.2" - "@docusaurus/utils-common" "3.9.2" - "@docusaurus/utils-validation" "3.9.2" +"@docusaurus/plugin-content-docs@3.10.0", "@docusaurus/plugin-content-docs@^2 || ^3", "@docusaurus/plugin-content-docs@^3.10.0": + version "3.10.0" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.10.0.tgz#9c4ea1d5a405340f28c281d2e4586c695a7c65a5" + integrity sha512-9BjHhf15ct8Z7TThTC0xRndKDVvMKmVsAGAN7W9FpNRzfMdScOGcXtLmcCWtJGvAezjOJIm6CxOYCy3Io5+RnQ== + dependencies: + "@docusaurus/core" "3.10.0" + "@docusaurus/logger" "3.10.0" + "@docusaurus/mdx-loader" "3.10.0" + "@docusaurus/module-type-aliases" "3.10.0" + "@docusaurus/theme-common" "3.10.0" + "@docusaurus/types" "3.10.0" + "@docusaurus/utils" "3.10.0" + "@docusaurus/utils-common" "3.10.0" + "@docusaurus/utils-validation" "3.10.0" "@types/react-router-config" "^5.0.7" combine-promises "^1.1.0" fs-extra "^11.1.1" @@ -1736,144 +1709,145 @@ utility-types "^3.10.0" webpack "^5.88.1" -"@docusaurus/plugin-content-pages@3.9.2": - version "3.9.2" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.9.2.tgz#22db6c88ade91cec0a9e87a00b8089898051b08d" - integrity sha512-s4849w/p4noXUrGpPUF0BPqIAfdAe76BLaRGAGKZ1gTDNiGxGcpsLcwJ9OTi1/V8A+AzvsmI9pkjie2zjIQZKA== - dependencies: - "@docusaurus/core" "3.9.2" - "@docusaurus/mdx-loader" "3.9.2" - "@docusaurus/types" "3.9.2" - "@docusaurus/utils" "3.9.2" - "@docusaurus/utils-validation" "3.9.2" +"@docusaurus/plugin-content-pages@3.10.0": + version "3.10.0" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.10.0.tgz#7670cbb3c849f434949f542bfdfded1580a13165" + integrity sha512-5amX8kEJI+nIGtuLVjYk59Y5utEJ3CHETFOPEE4cooIRLA4xM4iBsA6zFgu4ljcopeYwvBzFEWf5g2I6Yb9SkA== + dependencies: + "@docusaurus/core" "3.10.0" + "@docusaurus/mdx-loader" "3.10.0" + "@docusaurus/types" "3.10.0" + "@docusaurus/utils" "3.10.0" + "@docusaurus/utils-validation" "3.10.0" fs-extra "^11.1.1" tslib "^2.6.0" webpack "^5.88.1" -"@docusaurus/plugin-css-cascade-layers@3.9.2": - version "3.9.2" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-css-cascade-layers/-/plugin-css-cascade-layers-3.9.2.tgz#358c85f63f1c6a11f611f1b8889d9435c11b22f8" - integrity sha512-w1s3+Ss+eOQbscGM4cfIFBlVg/QKxyYgj26k5AnakuHkKxH6004ZtuLe5awMBotIYF2bbGDoDhpgQ4r/kcj4rQ== +"@docusaurus/plugin-css-cascade-layers@3.10.0": + version "3.10.0" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-css-cascade-layers/-/plugin-css-cascade-layers-3.10.0.tgz#71e318d842be95f92be6c3dca00ceea4971d0edb" + integrity sha512-6q1vtt5FJcg5osgkHeM1euErECNqEZ5Z1j69yiNx2luEBIso+nxCkS9nqj8w+MK5X7rvKEToGhFfOFWncs51pQ== dependencies: - "@docusaurus/core" "3.9.2" - "@docusaurus/types" "3.9.2" - "@docusaurus/utils" "3.9.2" - "@docusaurus/utils-validation" "3.9.2" + "@docusaurus/core" "3.10.0" + "@docusaurus/types" "3.10.0" + "@docusaurus/utils" "3.10.0" + "@docusaurus/utils-validation" "3.10.0" tslib "^2.6.0" -"@docusaurus/plugin-debug@3.9.2": - version "3.9.2" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-debug/-/plugin-debug-3.9.2.tgz#b5df4db115583f5404a252dbf66f379ff933e53c" - integrity sha512-j7a5hWuAFxyQAkilZwhsQ/b3T7FfHZ+0dub6j/GxKNFJp2h9qk/P1Bp7vrGASnvA9KNQBBL1ZXTe7jlh4VdPdA== +"@docusaurus/plugin-debug@3.10.0": + version "3.10.0" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-debug/-/plugin-debug-3.10.0.tgz#e77f924604e1e09d5d90fe0bdf23a3be8ea3307e" + integrity sha512-XcljKN+G+nmmK69uQA1d9BlYU3ZftG3T3zpK8/7Hf/wrOlV7TA4Ampdrdwkg0jElKdKAoSnPhCO0/U3bQGsVQQ== dependencies: - "@docusaurus/core" "3.9.2" - "@docusaurus/types" "3.9.2" - "@docusaurus/utils" "3.9.2" + "@docusaurus/core" "3.10.0" + "@docusaurus/types" "3.10.0" + "@docusaurus/utils" "3.10.0" fs-extra "^11.1.1" react-json-view-lite "^2.3.0" tslib "^2.6.0" -"@docusaurus/plugin-google-analytics@3.9.2": - version "3.9.2" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.9.2.tgz#857fe075fdeccdf6959e62954d9efe39769fa247" - integrity sha512-mAwwQJ1Us9jL/lVjXtErXto4p4/iaLlweC54yDUK1a97WfkC6Z2k5/769JsFgwOwOP+n5mUQGACXOEQ0XDuVUw== +"@docusaurus/plugin-google-analytics@3.10.0": + version "3.10.0" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.10.0.tgz#22c7e976fe4d970c7cd1c73c9723d9a5786c6e37" + integrity sha512-hTEoodatpBZnUat5nFExbuTGA1lhWGy7vZGuTew5Q3QDtGKFpSJLYmZJhdTjvCFwv1+qQ67hgAVlKdJOB8TXow== dependencies: - "@docusaurus/core" "3.9.2" - "@docusaurus/types" "3.9.2" - "@docusaurus/utils-validation" "3.9.2" + "@docusaurus/core" "3.10.0" + "@docusaurus/types" "3.10.0" + "@docusaurus/utils-validation" "3.10.0" tslib "^2.6.0" -"@docusaurus/plugin-google-gtag@3.9.2": - version "3.9.2" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.9.2.tgz#df75b1a90ae9266b0471909ba0265f46d5dcae62" - integrity sha512-YJ4lDCphabBtw19ooSlc1MnxtYGpjFV9rEdzjLsUnBCeis2djUyCozZaFhCg6NGEwOn7HDDyMh0yzcdRpnuIvA== +"@docusaurus/plugin-google-gtag@3.10.0": + version "3.10.0" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.10.0.tgz#c38a2ba638257851cc845b934506b80c08d47f96" + integrity sha512-iB/Zzjv/eelJRbdULZqzWCbgMgJ7ht4ONVjXtN3+BI/muil6S87gQ1OJyPwlXD+ELdKkitC7bWv5eJdYOZLhrQ== dependencies: - "@docusaurus/core" "3.9.2" - "@docusaurus/types" "3.9.2" - "@docusaurus/utils-validation" "3.9.2" - "@types/gtag.js" "^0.0.12" + "@docusaurus/core" "3.10.0" + "@docusaurus/types" "3.10.0" + "@docusaurus/utils-validation" "3.10.0" + "@types/gtag.js" "^0.0.20" tslib "^2.6.0" -"@docusaurus/plugin-google-tag-manager@3.9.2": - version "3.9.2" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.9.2.tgz#d1a3cf935acb7d31b84685e92d70a1d342946677" - integrity sha512-LJtIrkZN/tuHD8NqDAW1Tnw0ekOwRTfobWPsdO15YxcicBo2ykKF0/D6n0vVBfd3srwr9Z6rzrIWYrMzBGrvNw== +"@docusaurus/plugin-google-tag-manager@3.10.0": + version "3.10.0" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.10.0.tgz#5469c923cc1ad4608399d0b17e5fcacd8e030d56" + integrity sha512-FEjZxqKgLHa+Wez/EgKxRwvArNCWIScfyEQD95rot7jkxp6nonjI5XIbGfO/iYhM5Qinwe8aIEQHP2KZtpqVuA== dependencies: - "@docusaurus/core" "3.9.2" - "@docusaurus/types" "3.9.2" - "@docusaurus/utils-validation" "3.9.2" + "@docusaurus/core" "3.10.0" + "@docusaurus/types" "3.10.0" + "@docusaurus/utils-validation" "3.10.0" tslib "^2.6.0" -"@docusaurus/plugin-sitemap@3.9.2": - version "3.9.2" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.9.2.tgz#e1d9f7012942562cc0c6543d3cb2cdc4ae713dc4" - integrity sha512-WLh7ymgDXjG8oPoM/T4/zUP7KcSuFYRZAUTl8vR6VzYkfc18GBM4xLhcT+AKOwun6kBivYKUJf+vlqYJkm+RHw== - dependencies: - "@docusaurus/core" "3.9.2" - "@docusaurus/logger" "3.9.2" - "@docusaurus/types" "3.9.2" - "@docusaurus/utils" "3.9.2" - "@docusaurus/utils-common" "3.9.2" - "@docusaurus/utils-validation" "3.9.2" +"@docusaurus/plugin-sitemap@3.10.0": + version "3.10.0" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.10.0.tgz#35d59d46803f279f22aa64fc1bd18c048f12662b" + integrity sha512-DVTSLjB97hIjmayGnGcBfognCeI7ZuUKgEnU7Oz81JYqXtVg94mVTthDjq3QHTylYNeCUbkaW8VF0FDLcc8pPw== + dependencies: + "@docusaurus/core" "3.10.0" + "@docusaurus/logger" "3.10.0" + "@docusaurus/types" "3.10.0" + "@docusaurus/utils" "3.10.0" + "@docusaurus/utils-common" "3.10.0" + "@docusaurus/utils-validation" "3.10.0" fs-extra "^11.1.1" sitemap "^7.1.1" tslib "^2.6.0" -"@docusaurus/plugin-svgr@3.9.2": - version "3.9.2" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-svgr/-/plugin-svgr-3.9.2.tgz#62857ed79d97c0150d25f7e7380fdee65671163a" - integrity sha512-n+1DE+5b3Lnf27TgVU5jM1d4x5tUh2oW5LTsBxJX4PsAPV0JGcmI6p3yLYtEY0LRVEIJh+8RsdQmRE66wSV8mw== +"@docusaurus/plugin-svgr@3.10.0": + version "3.10.0" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-svgr/-/plugin-svgr-3.10.0.tgz#8ada2e6dd8318d20206a9b044fc091a5794ba3f0" + integrity sha512-lNljBESaETZqVBMPqkrGchr+UPT1eZzEPLmJhz8I76BxbjqgsUnRvrq6lQJ9sYjgmgX52KB7kkgczqd2yzoswQ== dependencies: - "@docusaurus/core" "3.9.2" - "@docusaurus/types" "3.9.2" - "@docusaurus/utils" "3.9.2" - "@docusaurus/utils-validation" "3.9.2" + "@docusaurus/core" "3.10.0" + "@docusaurus/types" "3.10.0" + "@docusaurus/utils" "3.10.0" + "@docusaurus/utils-validation" "3.10.0" "@svgr/core" "8.1.0" "@svgr/webpack" "^8.1.0" tslib "^2.6.0" webpack "^5.88.1" -"@docusaurus/preset-classic@^3.9.2": - version "3.9.2" - resolved "https://registry.yarnpkg.com/@docusaurus/preset-classic/-/preset-classic-3.9.2.tgz#85cc4f91baf177f8146c9ce896dfa1f0fd377050" - integrity sha512-IgyYO2Gvaigi21LuDIe+nvmN/dfGXAiMcV/murFqcpjnZc7jxFAxW+9LEjdPt61uZLxG4ByW/oUmX/DDK9t/8w== - dependencies: - "@docusaurus/core" "3.9.2" - "@docusaurus/plugin-content-blog" "3.9.2" - "@docusaurus/plugin-content-docs" "3.9.2" - "@docusaurus/plugin-content-pages" "3.9.2" - "@docusaurus/plugin-css-cascade-layers" "3.9.2" - "@docusaurus/plugin-debug" "3.9.2" - "@docusaurus/plugin-google-analytics" "3.9.2" - "@docusaurus/plugin-google-gtag" "3.9.2" - "@docusaurus/plugin-google-tag-manager" "3.9.2" - "@docusaurus/plugin-sitemap" "3.9.2" - "@docusaurus/plugin-svgr" "3.9.2" - "@docusaurus/theme-classic" "3.9.2" - "@docusaurus/theme-common" "3.9.2" - "@docusaurus/theme-search-algolia" "3.9.2" - "@docusaurus/types" "3.9.2" - -"@docusaurus/theme-classic@3.9.2": - version "3.9.2" - resolved "https://registry.yarnpkg.com/@docusaurus/theme-classic/-/theme-classic-3.9.2.tgz#6e514f99a0ff42b80afcf42d5e5d042618311ce0" - integrity sha512-IGUsArG5hhekXd7RDb11v94ycpJpFdJPkLnt10fFQWOVxAtq5/D7hT6lzc2fhyQKaaCE62qVajOMKL7OiAFAIA== - dependencies: - "@docusaurus/core" "3.9.2" - "@docusaurus/logger" "3.9.2" - "@docusaurus/mdx-loader" "3.9.2" - "@docusaurus/module-type-aliases" "3.9.2" - "@docusaurus/plugin-content-blog" "3.9.2" - "@docusaurus/plugin-content-docs" "3.9.2" - "@docusaurus/plugin-content-pages" "3.9.2" - "@docusaurus/theme-common" "3.9.2" - "@docusaurus/theme-translations" "3.9.2" - "@docusaurus/types" "3.9.2" - "@docusaurus/utils" "3.9.2" - "@docusaurus/utils-common" "3.9.2" - "@docusaurus/utils-validation" "3.9.2" +"@docusaurus/preset-classic@^3.10.0": + version "3.10.0" + resolved "https://registry.yarnpkg.com/@docusaurus/preset-classic/-/preset-classic-3.10.0.tgz#74b6facdaf568bcd41ec90cae9aebb7ca0ac8619" + integrity sha512-kw/Ye02Hc6xP1OdTswy8yxQEHg0fdPpyWAQRxr5b2x3h7LlG2Zgbb5BDFROnXDDMpUxB7YejlocJIE5HIEfpNA== + dependencies: + "@docusaurus/core" "3.10.0" + "@docusaurus/plugin-content-blog" "3.10.0" + "@docusaurus/plugin-content-docs" "3.10.0" + "@docusaurus/plugin-content-pages" "3.10.0" + "@docusaurus/plugin-css-cascade-layers" "3.10.0" + "@docusaurus/plugin-debug" "3.10.0" + "@docusaurus/plugin-google-analytics" "3.10.0" + "@docusaurus/plugin-google-gtag" "3.10.0" + "@docusaurus/plugin-google-tag-manager" "3.10.0" + "@docusaurus/plugin-sitemap" "3.10.0" + "@docusaurus/plugin-svgr" "3.10.0" + "@docusaurus/theme-classic" "3.10.0" + "@docusaurus/theme-common" "3.10.0" + "@docusaurus/theme-search-algolia" "3.10.0" + "@docusaurus/types" "3.10.0" + +"@docusaurus/theme-classic@3.10.0": + version "3.10.0" + resolved "https://registry.yarnpkg.com/@docusaurus/theme-classic/-/theme-classic-3.10.0.tgz#d937915c691189f27ced649c822994d839ea565b" + integrity sha512-9msCAsRdN+UG+RwPwCFb0uKy4tGoPh5YfBozXeGUtIeAgsMdn6f3G/oY861luZ3t8S2ET8S9Y/1GnpJAGWytww== + dependencies: + "@docusaurus/core" "3.10.0" + "@docusaurus/logger" "3.10.0" + "@docusaurus/mdx-loader" "3.10.0" + "@docusaurus/module-type-aliases" "3.10.0" + "@docusaurus/plugin-content-blog" "3.10.0" + "@docusaurus/plugin-content-docs" "3.10.0" + "@docusaurus/plugin-content-pages" "3.10.0" + "@docusaurus/theme-common" "3.10.0" + "@docusaurus/theme-translations" "3.10.0" + "@docusaurus/types" "3.10.0" + "@docusaurus/utils" "3.10.0" + "@docusaurus/utils-common" "3.10.0" + "@docusaurus/utils-validation" "3.10.0" "@mdx-js/react" "^3.0.0" clsx "^2.0.0" + copy-text-to-clipboard "^3.2.0" infima "0.2.0-alpha.45" lodash "^4.17.21" nprogress "^0.2.0" @@ -1885,15 +1859,15 @@ tslib "^2.6.0" utility-types "^3.10.0" -"@docusaurus/theme-common@3.9.2", "@docusaurus/theme-common@^3.9.2": - version "3.9.2" - resolved "https://registry.yarnpkg.com/@docusaurus/theme-common/-/theme-common-3.9.2.tgz#487172c6fef9815c2746ef62a71e4f5b326f9ba5" - integrity sha512-6c4DAbR6n6nPbnZhY2V3tzpnKnGL+6aOsLvFL26VRqhlczli9eWG0VDUNoCQEPnGwDMhPS42UhSAnz5pThm5Ag== +"@docusaurus/theme-common@3.10.0", "@docusaurus/theme-common@^3.9.2": + version "3.10.0" + resolved "https://registry.yarnpkg.com/@docusaurus/theme-common/-/theme-common-3.10.0.tgz#70b419ccfdf62f092299354a72d1692e81be597d" + integrity sha512-Dkp1YXKn16ByCJAdIjbDIOpVb4Z66MsVD694/ilX1vAAHaVEMrVsf/NPd9VgreyFx08rJ9GqV1MtzsbTcU73Kg== dependencies: - "@docusaurus/mdx-loader" "3.9.2" - "@docusaurus/module-type-aliases" "3.9.2" - "@docusaurus/utils" "3.9.2" - "@docusaurus/utils-common" "3.9.2" + "@docusaurus/mdx-loader" "3.10.0" + "@docusaurus/module-type-aliases" "3.10.0" + "@docusaurus/utils" "3.10.0" + "@docusaurus/utils-common" "3.10.0" "@types/history" "^4.7.11" "@types/react" "*" "@types/react-router-config" "*" @@ -1903,19 +1877,20 @@ tslib "^2.6.0" utility-types "^3.10.0" -"@docusaurus/theme-search-algolia@3.9.2": - version "3.9.2" - resolved "https://registry.yarnpkg.com/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.9.2.tgz#420fd5b27fc1673b48151fdc9fe7167ba135ed50" - integrity sha512-GBDSFNwjnh5/LdkxCKQHkgO2pIMX1447BxYUBG2wBiajS21uj64a+gH/qlbQjDLxmGrbrllBrtJkUHxIsiwRnw== - dependencies: - "@docsearch/react" "^3.9.0 || ^4.1.0" - "@docusaurus/core" "3.9.2" - "@docusaurus/logger" "3.9.2" - "@docusaurus/plugin-content-docs" "3.9.2" - "@docusaurus/theme-common" "3.9.2" - "@docusaurus/theme-translations" "3.9.2" - "@docusaurus/utils" "3.9.2" - "@docusaurus/utils-validation" "3.9.2" +"@docusaurus/theme-search-algolia@3.10.0": + version "3.10.0" + resolved "https://registry.yarnpkg.com/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.10.0.tgz#0ff57fe58db6abde8f5ad2877e459cd2fa6e7464" + integrity sha512-f5FPKI08e3JRG63vR/o4qeuUVHUHzFzM0nnF+AkB67soAZgNsKJRf2qmUZvlQkGwlV+QFkKe4D0ANMh1jToU3g== + dependencies: + "@algolia/autocomplete-core" "^1.19.2" + "@docsearch/react" "^3.9.0 || ^4.3.2" + "@docusaurus/core" "3.10.0" + "@docusaurus/logger" "3.10.0" + "@docusaurus/plugin-content-docs" "3.10.0" + "@docusaurus/theme-common" "3.10.0" + "@docusaurus/theme-translations" "3.10.0" + "@docusaurus/utils" "3.10.0" + "@docusaurus/utils-validation" "3.10.0" algoliasearch "^5.37.0" algoliasearch-helper "^3.26.0" clsx "^2.0.0" @@ -1925,7 +1900,15 @@ tslib "^2.6.0" utility-types "^3.10.0" -"@docusaurus/theme-translations@3.9.2", "@docusaurus/theme-translations@^2 || ^3": +"@docusaurus/theme-translations@3.10.0": + version "3.10.0" + resolved "https://registry.yarnpkg.com/@docusaurus/theme-translations/-/theme-translations-3.10.0.tgz#8fdc23d29bd7f907db49c36cf65e2123d96be300" + integrity sha512-L9IbFLwTc5+XdgH45iQYufLn0SVZd6BUNelDbKIFlH+E4hhjuj/XHWAFMX/w2K59rfy8wak9McOaei7BSUfRPA== + dependencies: + fs-extra "^11.1.1" + tslib "^2.6.0" + +"@docusaurus/theme-translations@^2 || ^3": version "3.9.2" resolved "https://registry.yarnpkg.com/@docusaurus/theme-translations/-/theme-translations-3.9.2.tgz#238cd69c2da92d612be3d3b4f95944c1d0f1e041" integrity sha512-vIryvpP18ON9T9rjgMRFLr2xJVDpw1rtagEGf8Ccce4CkTrvM/fRB8N2nyWYOW5u3DdjkwKw5fBa+3tbn9P4PA== @@ -1933,10 +1916,10 @@ fs-extra "^11.1.1" tslib "^2.6.0" -"@docusaurus/types@3.9.2": - version "3.9.2" - resolved "https://registry.yarnpkg.com/@docusaurus/types/-/types-3.9.2.tgz#e482cf18faea0d1fa5ce0e3f1e28e0f32d2593eb" - integrity sha512-Ux1JUNswg+EfUEmajJjyhIohKceitY/yzjRUpu04WXgvVz+fbhVC0p+R0JhvEu4ytw8zIAys2hrdpQPBHRIa8Q== +"@docusaurus/types@3.10.0": + version "3.10.0" + resolved "https://registry.yarnpkg.com/@docusaurus/types/-/types-3.10.0.tgz#a69232bba74b738fcf4671fd5f0f079366dd3d13" + integrity sha512-F0dOt3FOoO20rRaFK7whGFQZ3ggyrWEdQc/c8/UiRuzhtg4y1w9FspXH5zpCT07uMnJKBPGh+qNazbNlCQqvSw== dependencies: "@mdx-js/mdx" "^3.0.0" "@types/history" "^4.7.11" @@ -1949,38 +1932,38 @@ webpack "^5.95.0" webpack-merge "^5.9.0" -"@docusaurus/utils-common@3.9.2", "@docusaurus/utils-common@^2 || ^3": - version "3.9.2" - resolved "https://registry.yarnpkg.com/@docusaurus/utils-common/-/utils-common-3.9.2.tgz#e89bfcf43d66359f43df45293fcdf22814847460" - integrity sha512-I53UC1QctruA6SWLvbjbhCpAw7+X7PePoe5pYcwTOEXD/PxeP8LnECAhTHHwWCblyUX5bMi4QLRkxvyZ+IT8Aw== +"@docusaurus/utils-common@3.10.0", "@docusaurus/utils-common@^2 || ^3": + version "3.10.0" + resolved "https://registry.yarnpkg.com/@docusaurus/utils-common/-/utils-common-3.10.0.tgz#2a6dc76b312664fca7234d33607c085318ff1ae3" + integrity sha512-JyL7sb9QVDgYvudIS81Dv0lsWm7le0vGZSDwsztxWam1SPBqrnkvBy9UYL/amh6pbybkyYTd3CMTkO24oMlCSw== dependencies: - "@docusaurus/types" "3.9.2" + "@docusaurus/types" "3.10.0" tslib "^2.6.0" -"@docusaurus/utils-validation@3.9.2", "@docusaurus/utils-validation@^2 || ^3": - version "3.9.2" - resolved "https://registry.yarnpkg.com/@docusaurus/utils-validation/-/utils-validation-3.9.2.tgz#04aec285604790806e2fc5aa90aa950dc7ba75ae" - integrity sha512-l7yk3X5VnNmATbwijJkexdhulNsQaNDwoagiwujXoxFbWLcxHQqNQ+c/IAlzrfMMOfa/8xSBZ7KEKDesE/2J7A== +"@docusaurus/utils-validation@3.10.0", "@docusaurus/utils-validation@^2 || ^3": + version "3.10.0" + resolved "https://registry.yarnpkg.com/@docusaurus/utils-validation/-/utils-validation-3.10.0.tgz#a2418d7f31980d991fd3a1f39c8aad8820b36812" + integrity sha512-c+6n2+ZPOJtWWc8Bb/EYdpSDfjYEScdCu9fB/SNjOmSCf1IdVnGf2T53o0tsz0gDRtCL90tifTL0JE/oMuP1Mw== dependencies: - "@docusaurus/logger" "3.9.2" - "@docusaurus/utils" "3.9.2" - "@docusaurus/utils-common" "3.9.2" + "@docusaurus/logger" "3.10.0" + "@docusaurus/utils" "3.10.0" + "@docusaurus/utils-common" "3.10.0" fs-extra "^11.2.0" joi "^17.9.2" js-yaml "^4.1.0" lodash "^4.17.21" tslib "^2.6.0" -"@docusaurus/utils@3.9.2", "@docusaurus/utils@^2 || ^3": - version "3.9.2" - resolved "https://registry.yarnpkg.com/@docusaurus/utils/-/utils-3.9.2.tgz#ffab7922631c7e0febcb54e6d499f648bf8a89eb" - integrity sha512-lBSBiRruFurFKXr5Hbsl2thmGweAPmddhF3jb99U4EMDA5L+e5Y1rAkOS07Nvrup7HUMBDrCV45meaxZnt28nQ== +"@docusaurus/utils@3.10.0", "@docusaurus/utils@^2 || ^3": + version "3.10.0" + resolved "https://registry.yarnpkg.com/@docusaurus/utils/-/utils-3.10.0.tgz#ea7d7b0d325b60f728decc00bb3908d00ef86faf" + integrity sha512-T3B0WTigsIthe0D4LQa2k+7bJY+c3WS+Wq2JhcznOSpn1lSN64yNtHQXboCj3QnUs1EuAZszQG1SHKu5w5ZrlA== dependencies: - "@docusaurus/logger" "3.9.2" - "@docusaurus/types" "3.9.2" - "@docusaurus/utils-common" "3.9.2" + "@docusaurus/logger" "3.10.0" + "@docusaurus/types" "3.10.0" + "@docusaurus/utils-common" "3.10.0" escape-string-regexp "^4.0.0" - execa "5.1.1" + execa "^5.1.1" file-loader "^6.2.0" fs-extra "^11.1.1" github-slugger "^1.5.0" @@ -2379,11 +2362,6 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" -"@opentelemetry/api@1.9.0": - version "1.9.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.0.tgz#d03eba68273dc0f7509e2a3d5cba21eae10379fe" - integrity sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg== - "@parcel/watcher-android-arm64@2.5.1": version "2.5.1" resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz#507f836d7e2042f798c7d07ad19c3546f9848ac1" @@ -2540,11 +2518,6 @@ micromark-util-character "^1.1.0" micromark-util-symbol "^1.0.1" -"@standard-schema/spec@^1.0.0": - version "1.0.0" - resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.0.0.tgz#f193b73dc316c4170f2e82a881da0f550d551b9c" - integrity sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA== - "@svgr/babel-plugin-add-jsx-attribute@8.0.0": version "8.0.0" resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz#4001f5d5dd87fa13303e36ee106e3ff3a7eb8b22" @@ -2819,10 +2792,10 @@ "@types/qs" "*" "@types/serve-static" "^1" -"@types/gtag.js@^0.0.12": - version "0.0.12" - resolved "https://registry.yarnpkg.com/@types/gtag.js/-/gtag.js-0.0.12.tgz#095122edca896689bdfcdd73b057e23064d23572" - integrity sha512-YQV9bUsemkzG81Ea295/nF/5GijnD2Af7QhEofh7xu+kvCN6RdodgNwwGWXB5GMI3NoyvQo0odNctoH/qLMIpg== +"@types/gtag.js@^0.0.20": + version "0.0.20" + resolved "https://registry.yarnpkg.com/@types/gtag.js/-/gtag.js-0.0.20.tgz#e47edabb4ed5ecac90a079275958e6c929d7c08a" + integrity sha512-wwAbk3SA2QeU67unN7zPxjEHmPmlXwZXZvQEpbEUQuMCRGgKyE1m6XDuTUA9b6pCGb/GqJmdfMOY5LuDjJSbbg== "@types/hast@^3.0.0": version "3.0.4" @@ -3063,11 +3036,6 @@ resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8" integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g== -"@vercel/oidc@3.0.5": - version "3.0.5" - resolved "https://registry.yarnpkg.com/@vercel/oidc/-/oidc-3.0.5.tgz#bd8db7ee777255c686443413492db4d98ef49657" - integrity sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw== - "@vimeo/player@2.29.0": version "2.29.0" resolved "https://registry.yarnpkg.com/@vimeo/player/-/player-2.29.0.tgz#f620f4f936706f92c99a3807e7b7ae4a7be9452f" @@ -3250,16 +3218,6 @@ aggregate-error@^3.0.0: clean-stack "^2.0.0" indent-string "^4.0.0" -ai@5.0.113, ai@^5.0.30: - version "5.0.113" - resolved "https://registry.yarnpkg.com/ai/-/ai-5.0.113.tgz#1dd52516e79fcca093f5bea9c0d21eb0dbf6b985" - integrity sha512-26vivpSO/mzZj0k1Si2IpsFspp26ttQICHRySQiMrtWcRd5mnJMX2a8sG28vmZ38C+JUn1cWmfZrsLMxkSMw9g== - dependencies: - "@ai-sdk/gateway" "2.0.21" - "@ai-sdk/provider" "2.0.0" - "@ai-sdk/provider-utils" "3.0.19" - "@opentelemetry/api" "1.9.0" - ajv-formats@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-2.1.1.tgz#6e669400659eb74973bbf2e33327180a0996b520" @@ -3306,7 +3264,7 @@ algoliasearch-helper@^3.26.0: dependencies: "@algolia/events" "^4.0.1" -algoliasearch@^5.28.0, algoliasearch@^5.37.0: +algoliasearch@^5.37.0: version "5.46.0" resolved "https://registry.yarnpkg.com/algoliasearch/-/algoliasearch-5.46.0.tgz#109e8976a4aee611112306de735c8c205e7e6bf8" integrity sha512-7ML6fa2K93FIfifG3GMWhDEwT5qQzPTmoHKCTvhzGEwdbQ4n0yYUWZlLYT75WllTGJCJtNUI0C1ybN4BCegqvg== @@ -4035,6 +3993,11 @@ cookie@~0.7.1: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== +copy-text-to-clipboard@^3.2.0: + version "3.2.2" + resolved "https://registry.yarnpkg.com/copy-text-to-clipboard/-/copy-text-to-clipboard-3.2.2.tgz#99bc79db3f2d355ec33a08d573aff6804491ddb9" + integrity sha512-T6SqyLd1iLuqPA90J5N4cTalrtovCySh58iiZDGJ6FGznbclKh4UI+FGacQSgFzwKG77W7XT5gwbVEbd9cIH1A== + copy-webpack-plugin@^11.0.0: version "11.0.0" resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-11.0.0.tgz#96d4dbdb5f73d02dd72d0528d1958721ab72e04a" @@ -4054,11 +4017,6 @@ core-js-compat@^3.43.0: dependencies: browserslist "^4.28.0" -core-js-pure@^3.43.0: - version "3.47.0" - resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.47.0.tgz#1104df8a3b6eb9189fcc559b5a65b90f66e7e887" - integrity sha512-BcxeDbzUrRnXGYIVAGFtcGQVNpFcUhVjr6W7F8XktvQW2iJP9e66GP6xdKotCRFlrxBvNIBrhwKteRXqMV86Nw== - core-js@^3.31.1: version "3.47.0" resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.47.0.tgz#436ef07650e191afeb84c24481b298bd60eb4a17" @@ -4409,7 +4367,7 @@ depd@~1.1.2: resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ== -dequal@^2.0.0, dequal@^2.0.3: +dequal@^2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== @@ -4840,12 +4798,7 @@ events@^3.2.0: resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== -eventsource-parser@^3.0.6: - version "3.0.6" - resolved "https://registry.yarnpkg.com/eventsource-parser/-/eventsource-parser-3.0.6.tgz#292e165e34cacbc936c3c92719ef326d4aeb4e90" - integrity sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg== - -execa@5.1.1: +execa@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== @@ -6002,11 +5955,6 @@ json-schema-traverse@^1.0.0: resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== -json-schema@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5" - integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA== - json5@^2.1.2, json5@^2.2.3: version "2.2.3" resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" @@ -6193,11 +6141,6 @@ markdown-table@^3.0.0: resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-3.0.4.tgz#fe44d6d410ff9d6f2ea1797a3f60aa4d2b631c2a" integrity sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw== -marked@^16.3.0: - version "16.4.2" - resolved "https://registry.yarnpkg.com/marked/-/marked-16.4.2.tgz#4959a64be6c486f0db7467ead7ce288de54290a3" - integrity sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA== - math-intrinsics@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" @@ -6980,10 +6923,10 @@ minimalistic-assert@^1.0.0: resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== -minimatch@3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" - integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== +minimatch@3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.5.tgz#580c88f8d5445f2bd6aa8f3cadefa0de79fbd69e" + integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w== dependencies: brace-expansion "^1.1.7" @@ -8103,10 +8046,10 @@ rc@1.2.8: minimist "^1.2.0" strip-json-comments "~2.0.1" -react-dom@^19.2.4: - version "19.2.4" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.4.tgz#6fac6bd96f7db477d966c7ec17c1a2b1ad8e6591" - integrity sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ== +react-dom@^19.2.5: + version "19.2.5" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.5.tgz#b8768b10837d0b8e9ca5b9e2d58dff3d880ea25e" + integrity sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag== dependencies: scheduler "^0.27.0" @@ -8136,10 +8079,10 @@ react-json-view-lite@^2.3.0: resolved "https://registry.yarnpkg.com/react-json-view-lite/-/react-json-view-lite-2.5.0.tgz#c7ff011c7cc80e9900abc7aa4916c6a5c6d6c1c6" integrity sha512-tk7o7QG9oYyELWHL8xiMQ8x4WzjCzbWNyig3uexmkLb54r8jO0yH3WCWx8UZS0c49eSA4QUmG5caiRJ8fAn58g== -react-loadable-ssr-addon-v5-slorber@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/react-loadable-ssr-addon-v5-slorber/-/react-loadable-ssr-addon-v5-slorber-1.0.1.tgz#2cdc91e8a744ffdf9e3556caabeb6e4278689883" - integrity sha512-lq3Lyw1lGku8zUEJPDxsNm1AfYHBrO9Y1+olAYwpUJ2IGFBskM0DMKok97A6LWUpHm+o7IvQBOWu9MLenp9Z+A== +react-loadable-ssr-addon-v5-slorber@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/react-loadable-ssr-addon-v5-slorber/-/react-loadable-ssr-addon-v5-slorber-1.0.3.tgz#bb3791bf481222c63a5bc6b96ee23f68cb5614b9" + integrity sha512-GXfh9VLwB5ERaCsU6RULh7tkemeX15aNh6wuMEBtfdyMa7fFG8TXrhXlx1SoEK2Ty/l6XIkzzYIQmyaWW3JgdQ== dependencies: "@babel/runtime" "^7.10.3" @@ -8201,10 +8144,10 @@ react-router@5.3.4, react-router@^5.3.4: tiny-invariant "^1.0.2" tiny-warning "^1.0.0" -react@^19.2.4: - version "19.2.4" - resolved "https://registry.yarnpkg.com/react/-/react-19.2.4.tgz#438e57baa19b77cb23aab516cf635cd0579ee09a" - integrity sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ== +react@^19.2.5: + version "19.2.5" + resolved "https://registry.yarnpkg.com/react/-/react-19.2.5.tgz#c888ab8b8ef33e2597fae8bdb2d77edbdb42858b" + integrity sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA== readable-stream@^2.0.1: version "2.3.8" @@ -8693,15 +8636,15 @@ serialize-javascript@^6.0.0, serialize-javascript@^6.0.1, serialize-javascript@^ dependencies: randombytes "^2.1.0" -serve-handler@^6.1.6: - version "6.1.6" - resolved "https://registry.yarnpkg.com/serve-handler/-/serve-handler-6.1.6.tgz#50803c1d3e947cd4a341d617f8209b22bd76cfa1" - integrity sha512-x5RL9Y2p5+Sh3D38Fh9i/iQ5ZK+e4xuXRd/pGbM4D13tgo/MGwbttUk8emytcr1YYzBYs+apnUngBDFYfpjPuQ== +serve-handler@^6.1.7: + version "6.1.7" + resolved "https://registry.yarnpkg.com/serve-handler/-/serve-handler-6.1.7.tgz#e9bb864e87ee71e8dab874cde44d146b77e3fb78" + integrity sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg== dependencies: bytes "3.0.0" content-disposition "0.5.2" mime-types "2.1.18" - minimatch "3.1.2" + minimatch "3.1.5" path-is-inside "1.0.2" path-to-regexp "3.3.0" range-parser "1.2.0" @@ -9121,14 +9064,6 @@ svgo@^3.0.2, svgo@^3.2.0: picocolors "^1.0.0" sax "^1.5.0" -swr@^2.2.5: - version "2.3.7" - resolved "https://registry.yarnpkg.com/swr/-/swr-2.3.7.tgz#93ca89c9c06a6a8dab72e9d8e85a687123f40356" - integrity sha512-ZEquQ82QvalqTxhBVv/DlAg2mbmUjF4UgpPg9wwk4ufb9rQnZXh1iKyyKBqV6bQGu1Ie7L1QwSYO07qFIa1p+g== - dependencies: - dequal "^2.0.3" - use-sync-external-store "^1.4.0" - tapable@^2.0.0, tapable@^2.2.1, tapable@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.3.0.tgz#7e3ea6d5ca31ba8e078b560f0d83ce9a14aa8be6" @@ -9160,11 +9095,6 @@ thingies@^2.5.0: resolved "https://registry.yarnpkg.com/thingies/-/thingies-2.5.0.tgz#5f7b882c933b85989f8466b528a6247a6881e04f" integrity sha512-s+2Bwztg6PhWUD7XMfeYm5qliDdSiZm7M7n8KjTkIsm3l/2lgVRc2/Gx/v+ZX8lT4FMA+i8aQvhcWylldc+ZNw== -throttleit@2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/throttleit/-/throttleit-2.1.0.tgz#a7e4aa0bf4845a5bd10daa39ea0c783f631a07b4" - integrity sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw== - thunky@^1.0.2: version "1.1.0" resolved "https://registry.yarnpkg.com/thunky/-/thunky-1.1.0.tgz#5abaf714a9405db0504732bbccd2cedd9ef9537d" @@ -9424,11 +9354,6 @@ url-loader@^4.1.1: mime-types "^2.1.27" schema-utils "^3.0.0" -use-sync-external-store@^1.4.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d" - integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w== - util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" @@ -9776,11 +9701,6 @@ youtube-video-element@^1.8.0: resolved "https://registry.yarnpkg.com/youtube-video-element/-/youtube-video-element-1.8.1.tgz#1fa9a92e5b6d6346bd7ef9f5869a69f9916f00ed" integrity sha512-+5UuAGaj+5AnBf39huLVpy/4dLtR0rmJP1TxOHVZ81bac4ZHFpTtQ4Dz2FAn2GPnfXISezvUEaQoAdFW4hH9Xg== -zod@^4.1.8: - version "4.2.0" - resolved "https://registry.yarnpkg.com/zod/-/zod-4.2.0.tgz#01e86f2c2b6d525a1b9fa6dbe78beccad082118f" - integrity sha512-Bd5fw9wlIhtqCCxotZgdTOMwGm1a0u75wARVEY9HMs1X17trvA/lMi4+MGK5EUfYkXVTbX8UDiDKW4OgzHVUZw== - zwitch@^2.0.0: version "2.0.4" resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7" From 3ec6a9d9b86bdac61e13e265bd7e813212e1fef1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 07:00:59 +0000 Subject: [PATCH 53/64] Bump follow-redirects from 1.15.11 to 1.16.0 in /website (#4227) Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.11 to 1.16.0. - [Release notes](https://github.com/follow-redirects/follow-redirects/releases) - [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.11...v1.16.0) --- updated-dependencies: - dependency-name: follow-redirects dependency-version: 1.16.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- website/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/website/yarn.lock b/website/yarn.lock index ad850e281f..f4f6cd9519 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -4973,9 +4973,9 @@ flat@^5.0.2: integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== follow-redirects@^1.0.0: - version "1.15.11" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.11.tgz#777d73d72a92f8ec4d2e410eb47352a56b8e8340" - integrity sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ== + version "1.16.0" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.0.tgz#28474a159d3b9d11ef62050a14ed60e4df6d61bc" + integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw== form-data-encoder@^2.1.2: version "2.1.4" From 51b8a82962d75af4a5404a053a9292fa5104e65a Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Tue, 14 Apr 2026 14:31:35 +0200 Subject: [PATCH 54/64] Bump announced Scala 3 Next RC version to 3.8.4-RC1 (#4219) --- .../CoursierScalaInstallationTestHelper.scala | 22 ++++++++++++++----- project/deps/package.mill | 2 +- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/modules/integration/src/test/scala/scala/cli/integration/CoursierScalaInstallationTestHelper.scala b/modules/integration/src/test/scala/scala/cli/integration/CoursierScalaInstallationTestHelper.scala index a5390447ad..bd2ec11cce 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/CoursierScalaInstallationTestHelper.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/CoursierScalaInstallationTestHelper.scala @@ -41,11 +41,23 @@ trait CoursierScalaInstallationTestHelper { setCommandLine match { case scriptPathRegex(extractedPath) => extractedPath } val batchScriptPath = os.Path(batchScript) val oldContent = os.read(batchScriptPath) - val newContent = oldContent.replace( - "call %SCALA_CLI_CMD_WIN%", - s"""set "SCALA_CLI_CMD_WIN=${TestUtil.cliPath}" - |call %SCALA_CLI_CMD_WIN%""".stripMargin - ) + val cliPathWin = TestUtil.cliPath + val legacyInvoke = "call %SCALA_CLI_CMD_WIN%" + val newContent = + if oldContent.contains(legacyInvoke) then + oldContent.replace( + legacyInvoke, + s"""set "SCALA_CLI_CMD_WIN=$cliPathWin" + |call %SCALA_CLI_CMD_WIN%""".stripMargin + ) + else + // Scala 3.8.4+ Windows launcher uses delayed expansion (!SCALA_CLI_CMD_WIN!) instead of + // `call %SCALA_CLI_CMD_WIN%`. Override the variable after cli-common-platform.bat runs. + val anchor = + """call "%_PROG_HOME%\libexec\cli-common-platform.bat"""" + val injection = + s"$anchor${System.lineSeparator()}${System.lineSeparator()}set \"SCALA_CLI_CMD_WIN=$cliPathWin\"" + oldContent.replace(anchor, injection) expect(newContent != oldContent) os.write.over(batchScriptPath, newContent) batchWrapperScript -> batchScriptPath diff --git a/project/deps/package.mill b/project/deps/package.mill index 311cc1d8b7..e9bdbb111b 100644 --- a/project/deps/package.mill +++ b/project/deps/package.mill @@ -22,7 +22,7 @@ object Scala { def scala3Next = s"$scala3NextPrefix.3" // the newest/next version of Scala def scala3NextAnnounced = s"$scala3NextPrefix.2" // the newest/next version of Scala that's been announced def scala3NextRc = "3.8.4-RC1" // the latest RC version of Scala Next - def scala3NextRcAnnounced = "3.8.3-RC3" // the latest announced RC version of Scala Next + def scala3NextRcAnnounced = "3.8.4-RC1" // the latest announced RC version of Scala Next // The Scala version used to build the CLI itself. def defaultInternal = sys.props.get("scala.version.internal").getOrElse(scala3Lts) From 0d17e3331998924cd4def783df2ba74ec1a575ab Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Tue, 14 Apr 2026 16:10:05 +0200 Subject: [PATCH 55/64] Bump `scalafmt` to 3.11.0 (was 3.10.7) (#4228) --- .scalafmt.conf | 2 +- project/deps/package.mill | 2 +- website/docs/reference/cli-options.md | 2 +- website/docs/reference/scala-command/cli-options.md | 2 +- website/docs/reference/scala-command/runner-specification.md | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.scalafmt.conf b/.scalafmt.conf index 52fc3c8399..20db8812b3 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,4 +1,4 @@ -version = "3.10.7" +version = "3.11.0" align.preset = more maxColumn = 100 diff --git a/project/deps/package.mill b/project/deps/package.mill index e9bdbb111b..c494d96ecf 100644 --- a/project/deps/package.mill +++ b/project/deps/package.mill @@ -132,7 +132,7 @@ object Deps { def jsoniterScala = "2.38.8" def jsoup = "1.22.1" def scalaMeta = "4.15.2" - def scalafmt = "3.10.7" + def scalafmt = "3.11.0" def scalaNative04 = "0.4.17" def scalaNative05 = "0.5.10" def scalaNative = scalaNative05 diff --git a/website/docs/reference/cli-options.md b/website/docs/reference/cli-options.md index fa7cae7702..04eed6801c 100644 --- a/website/docs/reference/cli-options.md +++ b/website/docs/reference/cli-options.md @@ -505,7 +505,7 @@ Pass a global dialect for scalafmt. This overrides whatever value is configured Aliases: `--fmt-version` -Pass scalafmt version before running it (3.10.7 by default). If passed, this overrides whatever value is configured in the .scalafmt.conf file. +Pass scalafmt version before running it (3.11.0 by default). If passed, this overrides whatever value is configured in the .scalafmt.conf file. ## Global suppress warning options diff --git a/website/docs/reference/scala-command/cli-options.md b/website/docs/reference/scala-command/cli-options.md index 88d7a60235..4927872a08 100644 --- a/website/docs/reference/scala-command/cli-options.md +++ b/website/docs/reference/scala-command/cli-options.md @@ -390,7 +390,7 @@ Aliases: `--fmt-version` `IMPLEMENTATION specific` per Scala Runner specification -Pass scalafmt version before running it (3.10.7 by default). If passed, this overrides whatever value is configured in the .scalafmt.conf file. +Pass scalafmt version before running it (3.11.0 by default). If passed, this overrides whatever value is configured in the .scalafmt.conf file. ## Global suppress warning options diff --git a/website/docs/reference/scala-command/runner-specification.md b/website/docs/reference/scala-command/runner-specification.md index 58a9462945..dc4c04ae16 100644 --- a/website/docs/reference/scala-command/runner-specification.md +++ b/website/docs/reference/scala-command/runner-specification.md @@ -4058,7 +4058,7 @@ Aliases: `--dialect` **--scalafmt-version** -Pass scalafmt version before running it (3.10.7 by default). If passed, this overrides whatever value is configured in the .scalafmt.conf file. +Pass scalafmt version before running it (3.11.0 by default). If passed, this overrides whatever value is configured in the .scalafmt.conf file. Aliases: `--fmt-version` From 8ed45ff1103847d651b97a3683c7c9cf9448821c Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Tue, 14 Apr 2026 18:33:25 +0200 Subject: [PATCH 56/64] Bump Scala.js to 1.21.0 (#4229) --- project/deps/package.mill | 2 +- website/docs/guides/advanced/scala-js.md | 3 +- website/docs/reference/cli-options.md | 4 +-- website/docs/reference/directives.md | 2 +- .../reference/scala-command/cli-options.md | 4 +-- .../reference/scala-command/directives.md | 2 +- .../scala-command/runner-specification.md | 36 +++++++++---------- 7 files changed, 27 insertions(+), 26 deletions(-) diff --git a/project/deps/package.mill b/project/deps/package.mill index c494d96ecf..2502b82a9f 100644 --- a/project/deps/package.mill +++ b/project/deps/package.mill @@ -45,7 +45,7 @@ object Scala { val scala3MainVersions = (defaults ++ allScala3).distinct val runnerScalaVersions = (Seq(runnerScala3) ++ scala3MainVersions).distinct - def scalaJs = "1.20.2" + def scalaJs = "1.21.0" def scalaJsCli = scalaJs // this must be compatible with the Scala.js version private def patchVer(sv: String): Int = diff --git a/website/docs/guides/advanced/scala-js.md b/website/docs/guides/advanced/scala-js.md index 91f58ad725..e510896f2b 100644 --- a/website/docs/guides/advanced/scala-js.md +++ b/website/docs/guides/advanced/scala-js.md @@ -198,5 +198,6 @@ The table below lists the last supported version of Scala.js in Scala CLI. If yo | 1.6.2 - 1.7.1 | 1.18.2 | | 1.8.0 - 1.9.0 | 1.19.0 | | 1.9.1 - 1.11.0 | 1.20.1 | -| 1.12.0 - current | 1.20.2 | +| 1.12.0 - 1.12.5 | 1.20.2 | +| 1.13.0 - current | 1.21.0 | diff --git a/website/docs/reference/cli-options.md b/website/docs/reference/cli-options.md index 04eed6801c..ee063c86e8 100644 --- a/website/docs/reference/cli-options.md +++ b/website/docs/reference/cli-options.md @@ -1353,7 +1353,7 @@ Enable Scala.js. To show more options for Scala.js pass `--help-js` ### `--js-version` -The Scala.js version (1.20.2 by default). +The Scala.js version (1.21.0 by default). ### `--js-mode` @@ -1426,7 +1426,7 @@ Path to the Scala.js linker ### `--js-cli-version` [Internal] -Scala.js CLI version to use for linking (1.20.2 by default). +Scala.js CLI version to use for linking (1.21.0 by default). ### `--js-cli-java-arg` diff --git a/website/docs/reference/directives.md b/website/docs/reference/directives.md index a88985021d..6dad503eba 100644 --- a/website/docs/reference/directives.md +++ b/website/docs/reference/directives.md @@ -639,7 +639,7 @@ Add Scala.js options #### Examples -`//> using jsVersion 1.20.2` +`//> using jsVersion 1.21.0` `//> using jsMode mode` diff --git a/website/docs/reference/scala-command/cli-options.md b/website/docs/reference/scala-command/cli-options.md index 4927872a08..c0f441455e 100644 --- a/website/docs/reference/scala-command/cli-options.md +++ b/website/docs/reference/scala-command/cli-options.md @@ -772,7 +772,7 @@ Enable Scala.js. To show more options for Scala.js pass `--help-js` `SHOULD have` per Scala Runner specification -The Scala.js version (1.20.2 by default). +The Scala.js version (1.21.0 by default). ### `--js-mode` @@ -872,7 +872,7 @@ Path to the Scala.js linker `IMPLEMENTATION specific` per Scala Runner specification -Scala.js CLI version to use for linking (1.20.2 by default). +Scala.js CLI version to use for linking (1.21.0 by default). ### `--js-cli-java-arg` diff --git a/website/docs/reference/scala-command/directives.md b/website/docs/reference/scala-command/directives.md index 39124a0be2..8cf8a2967c 100644 --- a/website/docs/reference/scala-command/directives.md +++ b/website/docs/reference/scala-command/directives.md @@ -429,7 +429,7 @@ Add Scala.js options #### Examples -`//> using jsVersion 1.20.2` +`//> using jsVersion 1.21.0` `//> using jsMode mode` diff --git a/website/docs/reference/scala-command/runner-specification.md b/website/docs/reference/scala-command/runner-specification.md index dc4c04ae16..fbad9dc448 100644 --- a/website/docs/reference/scala-command/runner-specification.md +++ b/website/docs/reference/scala-command/runner-specification.md @@ -134,7 +134,7 @@ Enable Scala.js. To show more options for Scala.js pass `--help-js` **--js-version** -The Scala.js version (1.20.2 by default). +The Scala.js version (1.21.0 by default). **--js-mode** @@ -432,7 +432,7 @@ Path to the Scala.js linker **--js-cli-version** -Scala.js CLI version to use for linking (1.20.2 by default). +Scala.js CLI version to use for linking (1.21.0 by default). **--js-cli-java-arg** @@ -948,7 +948,7 @@ Enable Scala.js. To show more options for Scala.js pass `--help-js` **--js-version** -The Scala.js version (1.20.2 by default). +The Scala.js version (1.21.0 by default). **--js-mode** @@ -1234,7 +1234,7 @@ Path to the Scala.js linker **--js-cli-version** -Scala.js CLI version to use for linking (1.20.2 by default). +Scala.js CLI version to use for linking (1.21.0 by default). **--js-cli-java-arg** @@ -1561,7 +1561,7 @@ Enable Scala.js. To show more options for Scala.js pass `--help-js` **--js-version** -The Scala.js version (1.20.2 by default). +The Scala.js version (1.21.0 by default). **--js-mode** @@ -1853,7 +1853,7 @@ Path to the Scala.js linker **--js-cli-version** -Scala.js CLI version to use for linking (1.20.2 by default). +Scala.js CLI version to use for linking (1.21.0 by default). **--js-cli-java-arg** @@ -2200,7 +2200,7 @@ Enable Scala.js. To show more options for Scala.js pass `--help-js` **--js-version** -The Scala.js version (1.20.2 by default). +The Scala.js version (1.21.0 by default). **--js-mode** @@ -2502,7 +2502,7 @@ Path to the Scala.js linker **--js-cli-version** -Scala.js CLI version to use for linking (1.20.2 by default). +Scala.js CLI version to use for linking (1.21.0 by default). **--js-cli-java-arg** @@ -2858,7 +2858,7 @@ Enable Scala.js. To show more options for Scala.js pass `--help-js` **--js-version** -The Scala.js version (1.20.2 by default). +The Scala.js version (1.21.0 by default). **--js-mode** @@ -3160,7 +3160,7 @@ Path to the Scala.js linker **--js-cli-version** -Scala.js CLI version to use for linking (1.20.2 by default). +Scala.js CLI version to use for linking (1.21.0 by default). **--js-cli-java-arg** @@ -3492,7 +3492,7 @@ Enable Scala.js. To show more options for Scala.js pass `--help-js` **--js-version** -The Scala.js version (1.20.2 by default). +The Scala.js version (1.21.0 by default). **--js-mode** @@ -3776,7 +3776,7 @@ Path to the Scala.js linker **--js-cli-version** -Scala.js CLI version to use for linking (1.20.2 by default). +Scala.js CLI version to use for linking (1.21.0 by default). **--js-cli-java-arg** @@ -4163,7 +4163,7 @@ Enable Scala.js. To show more options for Scala.js pass `--help-js` **--js-version** -The Scala.js version (1.20.2 by default). +The Scala.js version (1.21.0 by default). **--js-mode** @@ -4470,7 +4470,7 @@ Path to the Scala.js linker **--js-cli-version** -Scala.js CLI version to use for linking (1.20.2 by default). +Scala.js CLI version to use for linking (1.21.0 by default). **--js-cli-java-arg** @@ -4894,7 +4894,7 @@ Enable Scala.js. To show more options for Scala.js pass `--help-js` **--js-version** -The Scala.js version (1.20.2 by default). +The Scala.js version (1.21.0 by default). **--js-mode** @@ -5174,7 +5174,7 @@ Path to the Scala.js linker **--js-cli-version** -Scala.js CLI version to use for linking (1.20.2 by default). +Scala.js CLI version to use for linking (1.21.0 by default). **--js-cli-java-arg** @@ -5881,7 +5881,7 @@ Enable Scala.js. To show more options for Scala.js pass `--help-js` **--js-version** -The Scala.js version (1.20.2 by default). +The Scala.js version (1.21.0 by default). **--js-mode** @@ -6161,7 +6161,7 @@ Path to the Scala.js linker **--js-cli-version** -Scala.js CLI version to use for linking (1.20.2 by default). +Scala.js CLI version to use for linking (1.21.0 by default). **--js-cli-java-arg** From 8d20222ef5946459aa88f572bd678a9ff618a92f Mon Sep 17 00:00:00 2001 From: Zia Ur Rehman <4042217+zrhmn@users.noreply.github.com> Date: Wed, 15 Apr 2026 06:46:36 -0400 Subject: [PATCH 57/64] Add additional packaging.graalvm* directives (#4225) * Add additional packaging.graalvm* directives * Add tests for invalid packaging.graalvmJavaVersion --- .../tests/PackagingUsingDirectiveTests.scala | 39 ++++++++++++++++++- .../preprocessing/directives/Packaging.scala | 33 ++++++++++++++-- website/docs/reference/directives.md | 10 ++++- 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/modules/build/src/test/scala/scala/build/tests/PackagingUsingDirectiveTests.scala b/modules/build/src/test/scala/scala/build/tests/PackagingUsingDirectiveTests.scala index b86e0f02f3..50ae23d8d0 100644 --- a/modules/build/src/test/scala/scala/build/tests/PackagingUsingDirectiveTests.scala +++ b/modules/build/src/test/scala/scala/build/tests/PackagingUsingDirectiveTests.scala @@ -34,7 +34,7 @@ class PackagingUsingDirectiveTests extends TestUtil.ScalaCliBuildSuite { } } - test("graalvm packaging") { + test("graalvm packaging jvmId") { val inputs = TestInputs( os.rel / "p.sc" -> """//> using packaging.packageType graalvm @@ -51,6 +51,43 @@ class PackagingUsingDirectiveTests extends TestUtil.ScalaCliBuildSuite { } } + test("graalvm packaging valid javaVersion") { + val inputs = TestInputs( + os.rel / "p.sc" -> + """//> using packaging.packageType graalvm + |//> using packaging.graalvmJavaVersion 23 + |//> using packaging.graalvmVersion 23.0.2 + | + |def foo() = println("hello foo") + |""".stripMargin + ) + inputs.withLoadedBuild(buildOptions, buildThreads, bloopConfig) { (_, _, maybeBuild) => + val nativeImageOpt = maybeBuild.options.notForBloopOptions.packageOptions.nativeImageOptions + expect(nativeImageOpt.jvmId == "graalvm-java23:23.0.2") + } + } + + test("graalvm packaging invalid javaVersion") { + val inputs = TestInputs( + os.rel / "p.sc" -> + """//> using packaging.packageType graalvm + |//> using packaging.graalvmJavaVersion 7 + | + |def foo() = println("hello foo") + |""".stripMargin + ) + inputs.withBuild(buildOptions, buildThreads, bloopConfig) { (_, _, maybeBuild) => + maybeBuild match + case Left(e) => + expect( + e.message.contains("graalvm-java-version") && + e.message.contains("an integer greater than 7") + ) + case Right(_) => + fail("Expected build to fail with invalid graalvmJavaVersion") + } + } + test("output") { val output = "foo" val inputs = TestInputs( diff --git a/modules/directives/src/main/scala/scala/build/preprocessing/directives/Packaging.scala b/modules/directives/src/main/scala/scala/build/preprocessing/directives/Packaging.scala index 7eb92e605e..3a4fca68c8 100644 --- a/modules/directives/src/main/scala/scala/build/preprocessing/directives/Packaging.scala +++ b/modules/directives/src/main/scala/scala/build/preprocessing/directives/Packaging.scala @@ -24,7 +24,9 @@ import scala.util.Try @DirectiveExamples("//> using packaging.output foo") @DirectiveExamples("//> using packaging.provided org.apache.spark::spark-sql") @DirectiveExamples("//> using packaging.graalvmArgs --no-fallback") -@DirectiveExamples("//> using packaging.graalvmJvmId graalvm-community:23.0.2") +@DirectiveExamples("//> using packaging.graalvmVersion 17.0.9") +@DirectiveExamples("//> using packaging.graalvmJavaVersion 17") +@DirectiveExamples("//> using packaging.graalvmJvmId graalvm-java17:17.0.9") @DirectiveExamples("//> using packaging.dockerFrom openjdk:11") @DirectiveExamples("//> using packaging.dockerImageTag 1.0.0") @DirectiveExamples("//> using packaging.dockerImageRegistry virtuslab") @@ -39,6 +41,9 @@ import scala.util.Try """using packaging.packageType [package type] |using packaging.output [destination path] |using packaging.provided [module] + |using packaging.graalvmVersion [graalvm version] + |using packaging.graalvmJavaVersion [graalvm java version] + |using packaging.graalvmJvmId [graalvm jvm id] |using packaging.graalvmArgs [args] |using packaging.dockerFrom [base docker image] |using packaging.dockerImageTag [image tag] @@ -55,6 +60,10 @@ import scala.util.Try | |`//> using packaging.graalvmArgs` _args_ | + |`//> using packaging.graalvmVersion` _graalvm-version_ + | + |`//> using packaging.graalvmJavaVersion` _graalvm-java-version_ + | |`//> using packaging.graalvmJvmId` _graalvm-jvm-id_ | |`//> using packaging.dockerFrom` _base-docker-image_ @@ -79,6 +88,8 @@ final case class Packaging( output: Option[String] = None, provided: List[Positioned[String]] = Nil, graalvmArgs: List[Positioned[String]] = Nil, + graalvmVersion: Option[String] = None, + graalvmJavaVersion: Option[Positioned[String]] = None, graalvmJvmId: Option[String] = None, dockerFrom: Option[String] = None, dockerImageTag: Option[String] = None, @@ -120,9 +131,23 @@ final case class Packaging( } .sequence .left.map(CompositeBuildException(_)) + val maybeGraalVMJavaVersion = graalvmJavaVersion + .map { version => + version.value.toIntOption + .filter(_ > 7) + .toRight { + new MalformedInputError( + "graalvm-java-version", + version.value, + "an integer greater than 7", + positions = version.positions + ) + } + } + .sequence - val (packageTypeOpt, output0, provided0) = value { - (maybePackageTypeOpt, maybeOutput, maybeProvided) + val (packageTypeOpt, output0, provided0, graalVMJavaVersion0) = value { + (maybePackageTypeOpt, maybeOutput, maybeProvided, maybeGraalVMJavaVersion) .traverseN .left.map(CompositeBuildException(_)) } @@ -162,6 +187,8 @@ final case class Packaging( extraDirectories = extraDirectories ), nativeImageOptions = NativeImageOptions( + graalvmVersion = graalvmVersion, + graalvmJavaVersion = graalVMJavaVersion0, graalvmJvmId = graalvmJvmId, graalvmArgs = graalvmArgs ) diff --git a/website/docs/reference/directives.md b/website/docs/reference/directives.md index 6dad503eba..1f093c234a 100644 --- a/website/docs/reference/directives.md +++ b/website/docs/reference/directives.md @@ -290,6 +290,10 @@ Set parameters for packaging `//> using packaging.graalvmArgs` _args_ +`//> using packaging.graalvmVersion` _graalvm-version_ + +`//> using packaging.graalvmJavaVersion` _graalvm-java-version_ + `//> using packaging.graalvmJvmId` _graalvm-jvm-id_ `//> using packaging.dockerFrom` _base-docker-image_ @@ -316,7 +320,11 @@ Set parameters for packaging `//> using packaging.graalvmArgs --no-fallback` -`//> using packaging.graalvmJvmId graalvm-community:23.0.2` +`//> using packaging.graalvmVersion 17.0.9` + +`//> using packaging.graalvmJavaVersion 17` + +`//> using packaging.graalvmJvmId graalvm-java17:17.0.9` `//> using packaging.dockerFrom openjdk:11` From ed37d805bf4a96acb2084747f4905cea95d2d0e1 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Wed, 15 Apr 2026 14:05:38 +0200 Subject: [PATCH 58/64] Add release notes for Scala CLI v1.13.0 (#4231) --- website/docs/release_notes.md | 111 ++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/website/docs/release_notes.md b/website/docs/release_notes.md index 1406e1074a..3a4b1b5a5a 100644 --- a/website/docs/release_notes.md +++ b/website/docs/release_notes.md @@ -8,6 +8,117 @@ import ReactPlayer from 'react-player' # Release notes +## [v1.13.0](https://github.com/VirtusLab/scala-cli/releases/tag/v1.13.0) + +### Change default Scala version to 3.8.3 +This Scala CLI version switches the default Scala version to 3.8.3. + +```bash +scala-cli version +# Scala CLI version: 1.13.0 +# Scala version (default): 3.8.3 +``` + +Added by [@Gedochao](https://github.com/Gedochao) in [#4204](https://github.com/VirtusLab/scala-cli/pull/4204) + +### Support for Scala.js 1.21.0 +This Scala CLI version adds support for Scala.js 1.21.0. + +```bash +scala-cli -e 'println("Hello")' --js +# Compiling project (Scala 3.8.3, Scala.js 1.21.0) +# Compiled project (Scala 3.8.3, Scala.js 1.21.0) +# Hello +``` + +Added by [@Gedochao](https://github.com/Gedochao) in [#4229](https://github.com/VirtusLab/scala-cli/pull/4229) + +### `java-test-runner` for pure Java tests +Projects with only Java sources (no Scala in the build) now use a dedicated `java-test-runner` module when +running `scala-cli test`. The new runner wires up Java-friendly test frameworks (such as JUnit via `junit-interface`) +without pulling the Scala test runner or Scala itself onto the test classpath. + +```java title=JavaTestRunnerExample.java compile +//> using test.dep junit:junit:4.13.2 +//> using test.dep com.novocode:junit-interface:0.11 +import org.junit.Test; +import static org.junit.Assert.assertEquals; + +public class JavaTestRunnerExample { + @Test + public void foo() { + assertEquals(4, 2 + 2); + } +} +``` + +Added by [@Gedochao](https://github.com/Gedochao) in [#4197](https://github.com/VirtusLab/scala-cli/pull/4197) + +### GraalVM native-image packaging: `packaging.graalvmJvmId` and `packaging.graalvmArgs` +GraalVM native-image packaging JVM id and args are now configurable from using directive level. + +```scala compile power +//> using packaging.packageType graalvm +//> using packaging.graalvmJvmId graalvm-community:23.0.2 +//> using packaging.graalvmArgs --no-fallback +``` +Added by [@zrhmn](https://github.com/zrhmn) in [#4223](https://github.com/VirtusLab/scala-cli/pull/4223) & [#4225](https://github.com/VirtusLab/scala-cli/pull/4225) + +### Features +* Add `java-test-runner` module to support running tests with pure Java by [@Gedochao](https://github.com/Gedochao) in [#4197](https://github.com/VirtusLab/scala-cli/pull/4197) +* Support `-opt-inline:help` by [@Gedochao](https://github.com/Gedochao) in [#4215](https://github.com/VirtusLab/scala-cli/pull/4215) +* Add directive `packaging.graalvmJvmId` by [@zrhmn](https://github.com/zrhmn) in [#4223](https://github.com/VirtusLab/scala-cli/pull/4223) +* Add additional `packaging.graalvm*` directives by [@zrhmn](https://github.com/zrhmn) in [#4225](https://github.com/VirtusLab/scala-cli/pull/4225) + +### Ammonite REPL deprecated & scheduled for removal +The Ammonite-backed REPL integration is now **deprecated** and **planned for removal** (in sync with [Ammonite's official communication](https://github.com/com-lihaoyi/Ammonite/commit/388d10819e9cb22c260be2fb1b053088d725ffb1)). +Flags such as `--ammonite`, `--ammonite-version`, and `--ammonite-arg` on `scala-cli repl` will go away in a future release. + +It's time to move on to the **default Scala REPL**. + +Added by [@Gedochao](https://github.com/Gedochao) in [#4218](https://github.com/VirtusLab/scala-cli/pull/4218) + +### Deprecations +* Add proper deprecation logic for features & deprecate Ammonite for removal by [@Gedochao](https://github.com/Gedochao) in [#4218](https://github.com/VirtusLab/scala-cli/pull/4218) + +### Fixes +* Add `signed-by` support to Debian APT repository by [@Gedochao](https://github.com/Gedochao) in [#4207](https://github.com/VirtusLab/scala-cli/pull/4207) +* Add missing attributes to ivy2 publishing by [@Gedochao](https://github.com/Gedochao) in [#4203](https://github.com/VirtusLab/scala-cli/pull/4203) +* Fix misc compiler warnings by [@Gedochao](https://github.com/Gedochao) in [#4220](https://github.com/VirtusLab/scala-cli/pull/4220) +* Make each packaged native image use its own subdirectory under `nativeImageWorkDir` when cross-packaging by [@Gedochao](https://github.com/Gedochao) in [#4221](https://github.com/VirtusLab/scala-cli/pull/4221) +* Support formatting `.sbt` inputs by [@Gedochao](https://github.com/Gedochao) in [#4195](https://github.com/VirtusLab/scala-cli/pull/4195) + +### Documentation changes + +### Build and internal changes +* Skip CI steps irrelevant to committed changes on PRs by [@Gedochao](https://github.com/Gedochao) in [#4208](https://github.com/VirtusLab/scala-cli/pull/4208) +* Run tests with JDK 26 by [@Gedochao](https://github.com/Gedochao) in [#4214](https://github.com/VirtusLab/scala-cli/pull/4214) +* Split `release_notes.md` into a separate test per-release-tag in `docs-tests` by [@Gedochao](https://github.com/Gedochao) in [#4216](https://github.com/VirtusLab/scala-cli/pull/4216) + +### Updates +* Update scala-cli.sh launcher for 1.12.5 by @github-actions[bot] in [#4191](https://github.com/VirtusLab/scala-cli/pull/4191) +* Bump Scala 3 Next RC to 3.8.3-RC3 by [@Gedochao](https://github.com/Gedochao) in [#4194](https://github.com/VirtusLab/scala-cli/pull/4194) +* Bump dorny/test-reporter from 2 to 3 in the github-actions group by @dependabot[bot] in [#4198](https://github.com/VirtusLab/scala-cli/pull/4198) +* Bump picomatch from 2.3.1 to 2.3.2 in /website by @dependabot[bot] in [#4200](https://github.com/VirtusLab/scala-cli/pull/4200) +* Bump node-forge from 1.3.3 to 1.4.0 in /website by @dependabot[bot] in [#4202](https://github.com/VirtusLab/scala-cli/pull/4202) +* Bump Scala 3 Next to 3.8.3 by [@Gedochao](https://github.com/Gedochao) in [#4204](https://github.com/VirtusLab/scala-cli/pull/4204) +* Bump brace-expansion from 1.1.12 to 1.1.13 in /website by @dependabot[bot] in [#4205](https://github.com/VirtusLab/scala-cli/pull/4205) +* Bump @algolia/client-search from 5.49.2 to 5.50.0 in /website in the npm-dependencies group by @dependabot[bot] in [#4206](https://github.com/VirtusLab/scala-cli/pull/4206) +* Bump the npm-dependencies group in /website with 2 updates by @dependabot[bot] in [#4211](https://github.com/VirtusLab/scala-cli/pull/4211) +* Bump lodash from 4.17.23 to 4.18.1 in /website by @dependabot[bot] in [#4212](https://github.com/VirtusLab/scala-cli/pull/4212) +* Bump Scala 3 Next RC to 3.8.4-RC1 by [@Gedochao](https://github.com/Gedochao) in [#4213](https://github.com/VirtusLab/scala-cli/pull/4213) +* Bump Mill to 1.1.5 (was 1.1.3) by [@Gedochao](https://github.com/Gedochao) in [#4217](https://github.com/VirtusLab/scala-cli/pull/4217) +* Bump the npm-dependencies group in /website with 6 updates by @dependabot[bot] in [#4226](https://github.com/VirtusLab/scala-cli/pull/4226) +* Bump follow-redirects from 1.15.11 to 1.16.0 in /website by @dependabot[bot] in [#4227](https://github.com/VirtusLab/scala-cli/pull/4227) +* Bump announced Scala 3 Next RC version to 3.8.4-RC1 by [@Gedochao](https://github.com/Gedochao) in [#4219](https://github.com/VirtusLab/scala-cli/pull/4219) +* Bump `scalafmt` to 3.11.0 (was 3.10.7) by [@Gedochao](https://github.com/Gedochao) in [#4228](https://github.com/VirtusLab/scala-cli/pull/4228) +* Bump Scala.js to 1.21.0 by [@Gedochao](https://github.com/Gedochao) in [#4229](https://github.com/VirtusLab/scala-cli/pull/4229) + +## New Contributors +* [@zrhmn](https://github.com/zrhmn) made their first contribution in [#4223](https://github.com/VirtusLab/scala-cli/pull/4223) + +**Full Changelog**: https://github.com/VirtusLab/scala-cli/compare/v1.12.5...v1.13.0 + ## [v1.12.5](https://github.com/VirtusLab/scala-cli/releases/tag/v1.12.5) ### `--cross` support for `run`, `package` and `doc` sub-commands (experimental ⚡️) From 2de46c7dfbb763051f3f886f48e4b64a372de8db Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 07:41:10 +0000 Subject: [PATCH 59/64] Update scala-cli.sh launcher for 1.13.0 (#4232) Co-authored-by: gh-actions --- scala-cli.bat | 2 +- scala-cli.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scala-cli.bat b/scala-cli.bat index 046d5b2a77..e0730f9b0a 100644 --- a/scala-cli.bat +++ b/scala-cli.bat @@ -7,7 +7,7 @@ rem Download the latest version of this script at https://github.com/VirtusLab/s setlocal enabledelayedexpansion -set "SCALA_CLI_VERSION=1.12.5" +set "SCALA_CLI_VERSION=1.13.0" set SCALA_CLI_URL=https://github.com/VirtusLab/scala-cli/releases/download/v%SCALA_CLI_VERSION%/scala-cli.bat set CACHE_BASE=%localappdata%/Coursier/v1 diff --git a/scala-cli.sh b/scala-cli.sh index d4b68849fa..a808029e59 100755 --- a/scala-cli.sh +++ b/scala-cli.sh @@ -7,7 +7,7 @@ set -eu -SCALA_CLI_VERSION="1.12.5" +SCALA_CLI_VERSION="1.13.0" GH_ORG="VirtusLab" GH_NAME="scala-cli" From 2bee48ca9264cb57b81a528f52aa955a34726808 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Fri, 17 Apr 2026 14:19:00 +0200 Subject: [PATCH 60/64] Migrate from old `using_directives` to Scala-rewritten `directives-parser` module (#4192) * Migrate from old `using_directives` to Scala-rewritten `directives-parser` module * Refactor tests * Handle edge cases * Include `directives-parser` module in `AGENTS.md` --- AGENTS.md | 9 +- build.mill | 24 +- .../CustomDirectivesReporter.scala | 55 -- .../preprocessing/ExtractedDirectives.scala | 104 ++-- .../preprocessing/UsingDirectivesOps.scala | 55 -- .../build/errors/NoValueProvidedError.scala | 7 - .../scala/cli/parse/CommentExtractor.scala | 177 ++++++ .../scala/scala/cli/parse/Diagnostics.scala | 10 + .../scala/cli/parse/DirectiveValue.scala | 37 ++ .../main/scala/scala/cli/parse/Lexer.scala | 172 ++++++ .../main/scala/scala/cli/parse/Parser.scala | 173 ++++++ .../main/scala/scala/cli/parse/Position.scala | 4 + .../main/scala/scala/cli/parse/Token.scala | 42 ++ .../scala/cli/parse/UsingDirective.scala | 15 + .../cli/parse/UsingDirectivesParser.scala | 57 ++ .../cli/parse/CommentExtractorTests.scala | 379 +++++++++++++ .../scala/scala/cli/parse/LexerTests.scala | 169 ++++++ .../scala/scala/cli/parse/ParserTests.scala | 508 ++++++++++++++++++ .../directives/DirectiveValueParser.scala | 42 +- .../scala/build/directives/ScopedValue.scala | 4 +- .../errors/SingleValueExpectedError.scala | 2 +- .../directives/DirectiveHandler.scala | 59 +- .../directives/DirectiveUtil.scala | 42 +- .../preprocessing/directives/Exclude.scala | 1 - .../directives/StrictDirective.scala | 51 +- .../cli/integration/PublishSetupTests.scala | 26 +- project/deps/package.mill | 1 - 27 files changed, 1908 insertions(+), 317 deletions(-) delete mode 100644 modules/build/src/main/scala/scala/build/preprocessing/CustomDirectivesReporter.scala delete mode 100644 modules/build/src/main/scala/scala/build/preprocessing/UsingDirectivesOps.scala delete mode 100644 modules/core/src/main/scala/scala/build/errors/NoValueProvidedError.scala create mode 100644 modules/directives-parser/src/main/scala/scala/cli/parse/CommentExtractor.scala create mode 100644 modules/directives-parser/src/main/scala/scala/cli/parse/Diagnostics.scala create mode 100644 modules/directives-parser/src/main/scala/scala/cli/parse/DirectiveValue.scala create mode 100644 modules/directives-parser/src/main/scala/scala/cli/parse/Lexer.scala create mode 100644 modules/directives-parser/src/main/scala/scala/cli/parse/Parser.scala create mode 100644 modules/directives-parser/src/main/scala/scala/cli/parse/Position.scala create mode 100644 modules/directives-parser/src/main/scala/scala/cli/parse/Token.scala create mode 100644 modules/directives-parser/src/main/scala/scala/cli/parse/UsingDirective.scala create mode 100644 modules/directives-parser/src/main/scala/scala/cli/parse/UsingDirectivesParser.scala create mode 100644 modules/directives-parser/src/test/scala/scala/cli/parse/CommentExtractorTests.scala create mode 100644 modules/directives-parser/src/test/scala/scala/cli/parse/LexerTests.scala create mode 100644 modules/directives-parser/src/test/scala/scala/cli/parse/ParserTests.scala diff --git a/AGENTS.md b/AGENTS.md index 84d41dd48d..ae1a1e5508 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,6 +51,8 @@ Modules live under `modules/`. The dependency graph flows roughly as: ``` specification-level → config → core → options → directives → build-module → cli + ↑ + directives-parser ``` ### Module overview @@ -64,6 +66,7 @@ The list below may not be exhaustive — check `modules/` and `build.mill` for t | `build-macros` | Compile-time macros (e.g. `EitherCps`). | | `core` | Core types: `Inputs`, `Sources`, build constants, Bloop integration, JVM/JS/Native tooling. | | `options` | `BuildOptions`, `SharedOptions`, and all option types. | +| `directives-parser` | Pure Scala 3 parser for `//> using` directive syntax: comment extraction, lexing, and parsing into AST nodes. | | `directives` | Using directive handlers — the bridge between `//> using` directives and `BuildOptions`. | | `build-module` (aliased from `build` in mill) | The main build pipeline: preprocessing, compilation, post-processing. Most business logic lives here. | | `cli` | Command definitions, argument parsing (CaseApp), the `ScalaCli` entry point. Packaged as the native image. | @@ -106,9 +109,9 @@ Using directives are in-source configuration comments: //> using test.dep org.scalameta::munit::1.1.1 ``` -Directives are parsed by `using_directives`, then `ExtractedDirectives` → `DirectivesPreprocessor` → `BuildOptions`/ -`BuildRequirements`. **CLI options override directive values.** To add a new directive, -see [agentskills/adding-directives/](agentskills/adding-directives/SKILL.md). +Directives are parsed by the `directives-parser` module (`CommentExtractor` → `Lexer` → `Parser`), then +`ExtractedDirectives` → `DirectivesPreprocessor` → `BuildOptions`/`BuildRequirements`. **CLI options override directive +values.** To add a new directive, see [agentskills/adding-directives/](agentskills/adding-directives/SKILL.md). ## Testing diff --git a/build.mill b/build.mill index efa7ac599e..e43785f8f7 100644 --- a/build.mill +++ b/build.mill @@ -98,6 +98,8 @@ object `specification-level` extends Cross[SpecificationLevel](Scala.scala3MainV with CrossScalaDefaultToInternal object `build-macros` extends Cross[BuildMacros](Scala.scala3MainVersions) with CrossScalaDefaultToInternal +object `directives-parser` extends Cross[DirectivesParserModule](Scala.scala3MainVersions) + with CrossScalaDefaultToInternal object config extends Cross[Config](Scala.scala3MainVersions) with CrossScalaDefaultToInternal object options extends Cross[Options](Scala.scala3MainVersions) @@ -616,7 +618,8 @@ trait Directives extends ScalaCliCrossSbtModule options(crossScalaVersion), core(crossScalaVersion), `build-macros`(crossScalaVersion), - `specification-level`(crossScalaVersion) + `specification-level`(crossScalaVersion), + `directives-parser`(crossScalaVersion) ) override def scalacOptions: T[Seq[String]] = Task { super.scalacOptions() ++ asyncScalacOptions(crossScalaVersion) @@ -630,8 +633,7 @@ trait Directives extends ScalaCliCrossSbtModule // Deps.asm, Deps.bloopConfig, Deps.jsoniterCore, - Deps.pprint, - Deps.usingDirectives + Deps.pprint ) override def repositoriesTask: Task[Seq[Repository]] = @@ -806,6 +808,16 @@ trait Build extends ScalaCliCrossSbtModule } } +trait DirectivesParserModule extends ScalaCliCrossSbtModule + with ScalaCliPublishModule + with HasTests + with ScalaCliScalafixModule + with LocatedInModules { + override def crossScalaVersion: String = crossValue + + object test extends ScalaCliTests with ScalaCliScalafixModule +} + trait SpecificationLevel extends ScalaCliCrossSbtModule with ScalaCliPublishModule with LocatedInModules { @@ -1024,6 +1036,9 @@ trait CliIntegration extends SbtModule ) trait IntegrationScalaTests extends super.ScalaCliTests with ScalaCliScalafixModule { + override def moduleDeps: Seq[JavaModule] = super.moduleDeps ++ Seq( + `directives-parser`(sv) + ) override def mvnDeps: T[Seq[Dep]] = super.mvnDeps() ++ Seq( Deps.bsp4j, Deps.coursier @@ -1032,8 +1047,7 @@ trait CliIntegration extends SbtModule Deps.jsoniterCore, Deps.libsodiumjni, Deps.pprint, - Deps.slf4jNop, - Deps.usingDirectives + Deps.slf4jNop ) override def compileMvnDeps: T[Seq[Dep]] = super.compileMvnDeps() ++ Seq( Deps.jsoniterMacros diff --git a/modules/build/src/main/scala/scala/build/preprocessing/CustomDirectivesReporter.scala b/modules/build/src/main/scala/scala/build/preprocessing/CustomDirectivesReporter.scala deleted file mode 100644 index 1e03d0bbb4..0000000000 --- a/modules/build/src/main/scala/scala/build/preprocessing/CustomDirectivesReporter.scala +++ /dev/null @@ -1,55 +0,0 @@ -package scala.build.preprocessing - -import com.virtuslab.using_directives.custom.utils.Position as DirectivePosition -import com.virtuslab.using_directives.reporter.Reporter - -import scala.build.Position -import scala.build.errors.{Diagnostic, Severity} - -class CustomDirectivesReporter(path: Either[String, os.Path], onDiagnostic: Diagnostic => Unit) - extends Reporter { - private var errorCount = 0 - private var warningCount = 0 - - private def toScalaCliPosition(position: DirectivePosition): Position = { - val coords = (position.getLine, position.getColumn) - Position.File(path, coords, coords) - } - - override def error(msg: String): Unit = - onDiagnostic { - errorCount += 1 - Diagnostic(msg, Severity.Error) - } - override def error(position: DirectivePosition, msg: String): Unit = - onDiagnostic { - errorCount += 1 - Diagnostic(msg, Severity.Error, Seq(toScalaCliPosition(position))) - } - override def warning(msg: String): Unit = - onDiagnostic { - warningCount += 1 - Diagnostic(msg, Severity.Warning) - } - override def warning(position: DirectivePosition, msg: String): Unit = - onDiagnostic { - warningCount += 1 - Diagnostic(msg, Severity.Warning, Seq(toScalaCliPosition(position))) - } - - override def hasErrors(): Boolean = - errorCount != 0 - - override def hasWarnings(): Boolean = - warningCount != 0 - - override def reset(): Unit = { - errorCount = 0 - } -} - -object CustomDirectivesReporter { - def create(path: Either[String, os.Path])(onDiagnostic: Diagnostic => Unit) - : CustomDirectivesReporter = - new CustomDirectivesReporter(path, onDiagnostic) -} diff --git a/modules/build/src/main/scala/scala/build/preprocessing/ExtractedDirectives.scala b/modules/build/src/main/scala/scala/build/preprocessing/ExtractedDirectives.scala index bba577ca0f..1bbc18b141 100644 --- a/modules/build/src/main/scala/scala/build/preprocessing/ExtractedDirectives.scala +++ b/modules/build/src/main/scala/scala/build/preprocessing/ExtractedDirectives.scala @@ -1,17 +1,12 @@ package scala.build.preprocessing -import com.virtuslab.using_directives.UsingDirectivesProcessor -import com.virtuslab.using_directives.custom.model.{BooleanValue, EmptyValue, StringValue, Value} -import com.virtuslab.using_directives.custom.utils.ast.* - import scala.annotation.targetName import scala.build.errors.* import scala.build.options.SuppressWarningOptions -import scala.build.preprocessing.UsingDirectivesOps.* import scala.build.preprocessing.directives.StrictDirective import scala.build.{Logger, Position} +import scala.cli.parse.{DiagnosticSeverity, DirectiveValue, UsingDirectivesParser} import scala.collection.mutable -import scala.jdk.CollectionConverters.* case class ExtractedDirectives( directives: Seq[StrictDirective], @@ -33,65 +28,68 @@ object ExtractedDirectives { logger: Logger, maybeRecoverOnError: BuildException => Option[BuildException] ): Either[BuildException, ExtractedDirectives] = { - val errors = new mutable.ListBuffer[Diagnostic] - val reporter = CustomDirectivesReporter - .create(path) { - case diag - if diag.severity == Severity.Warning && - diag.message.toLowerCase.contains("deprecated") && - suppressWarningOptions.suppressDeprecatedFeatureWarning.getOrElse(false) => - () // skip deprecated feature warnings if suppressed - case diag if diag.severity == Severity.Warning => - logger.log(Seq(diag)) - case diag => errors += diag - } - val processor = new UsingDirectivesProcessor(reporter) - val allDirectives = processor.extract(contentChars).asScala + val result = UsingDirectivesParser.parse(contentChars) + val diagnosticErrors = mutable.ListBuffer.empty[Diagnostic] + + for diag <- result.diagnostics do + val positions = diag.position.map { p => + Position.File(path, (p.line, p.column), (p.line, p.column)) + }.toSeq + + if diag.severity == DiagnosticSeverity.Warning then + if diag.message.toLowerCase.contains("deprecated") && + suppressWarningOptions.suppressDeprecatedFeatureWarning.getOrElse(false) + then () // skip + else logger.log(Seq(Diagnostic(diag.message, Severity.Warning, positions))) + else + diagnosticErrors += Diagnostic(diag.message, Severity.Error, positions) + val malformedDirectiveErrors = - errors.map(diag => new MalformedDirectiveError(diag.message, diag.positions)).toSeq + diagnosticErrors + .map(diag => new MalformedDirectiveError(diag.message, diag.positions)) + .toSeq + val maybeCompositeMalformedDirectiveError = - if (malformedDirectiveErrors.nonEmpty) + if malformedDirectiveErrors.nonEmpty then maybeRecoverOnError(CompositeBuildException(malformedDirectiveErrors)) else None - if (malformedDirectiveErrors.isEmpty || maybeCompositeMalformedDirectiveError.isEmpty) { - val directivesOpt = allDirectives.headOption - val directivesPositionOpt = directivesOpt match { - case Some(directives) - if directives.containsTargetDirectives || - directives.isEmpty => None - case Some(directives) => Some(directives.getPosition(path)) - case None => None - } + if malformedDirectiveErrors.isEmpty || maybeCompositeMalformedDirectiveError.isEmpty then + val directives = result.directives + + val containsTargetDirectives = directives.exists(_.key.startsWith("target.")) - val strictDirectives = directivesOpt.toSeq.flatMap { directives => - def toStrictValue(value: UsingValue): Seq[Value[?]] = value match { - case uvs: UsingValues => uvs.values.asScala.toSeq.flatMap(toStrictValue) - case el: EmptyLiteral => Seq(EmptyValue(el)) - case sl: StringLiteral => Seq(StringValue(sl.getValue(), sl)) - case bl: BooleanLiteral => Seq(BooleanValue(bl.getValue(), bl)) - } - def toStrictDirective(ud: UsingDef) = - StrictDirective( - ud.getKey(), - toStrictValue(ud.getValue()), - ud.getPosition().getColumn(), - ud.getPosition().getLine() - ) + val directivesPositionOpt = + if containsTargetDirectives || directives.isEmpty then None + else + val lastDirective = directives.last + val (endLine, endCol) = lastDirective.values.lastOption match + case Some(sv: DirectiveValue.StringVal) if sv.isQuoted => + (sv.pos.line, sv.pos.column + sv.value.length + 2) + case Some(sv: DirectiveValue.StringVal) => + (sv.pos.line, sv.pos.column + sv.value.length) + case Some(bv: DirectiveValue.BoolVal) => + (bv.pos.line, bv.pos.column + bv.value.toString.length) + case Some(ev: DirectiveValue.EmptyVal) => + (ev.pos.line, ev.pos.column) + case None => + val kp = lastDirective.keyPosition + (kp.line, kp.column + lastDirective.key.length) + Some(Position.File(path, (0, 0), (endLine, endCol), result.codeOffset)) - directives.getAst match - case uds: UsingDefs => uds.getUsingDefs.asScala.toSeq.map(toStrictDirective) - case ud: UsingDef => Seq(toStrictDirective(ud)) - case _ => Nil // There should be nothing else here other than UsingDefs or UsingDef + val strictDirectives = directives.map { ud => + StrictDirective( + ud.key, + ud.values, + ud.keyPosition.column, + ud.keyPosition.line + ) } Right(ExtractedDirectives(strictDirectives.reverse, directivesPositionOpt)) - } else - maybeCompositeMalformedDirectiveError match { + maybeCompositeMalformedDirectiveError match case Some(e) => Left(e) case None => Right(ExtractedDirectives.empty) - } } - } diff --git a/modules/build/src/main/scala/scala/build/preprocessing/UsingDirectivesOps.scala b/modules/build/src/main/scala/scala/build/preprocessing/UsingDirectivesOps.scala deleted file mode 100644 index 9409dd876c..0000000000 --- a/modules/build/src/main/scala/scala/build/preprocessing/UsingDirectivesOps.scala +++ /dev/null @@ -1,55 +0,0 @@ -package scala.build.preprocessing - -import com.virtuslab.using_directives.custom.model.UsingDirectives -import com.virtuslab.using_directives.custom.utils.ast.* - -import scala.annotation.tailrec -import scala.build.Position -import scala.jdk.CollectionConverters.* - -object UsingDirectivesOps { - extension (ud: UsingDirectives) { - def keySet: Set[String] = ud.getFlattenedMap.keySet().asScala.map(_.toString).toSet - def containsTargetDirectives: Boolean = ud.keySet.exists(_.startsWith("target.")) - - def getPosition(path: Either[String, os.Path]): Position.File = - extension (pos: Positioned) { - def getLine = pos.getPosition.getLine - def getColumn = pos.getPosition.getColumn - } - - @tailrec - def getEndPostion(ast: UsingTree): (Int, Int) = ast match { - case uds: UsingDefs => uds.getUsingDefs.asScala match { - case _ :+ lastUsingDef => getEndPostion(lastUsingDef) - case _ => (uds.getLine, uds.getColumn) - } - case ud: UsingDef => getEndPostion(ud.getValue) - case uvs: UsingValues => uvs.getValues.asScala match { - case _ :+ lastUsingValue => getEndPostion(lastUsingValue) - case _ => (uvs.getLine, uvs.getColumn) - } - case sl: StringLiteral => ( - sl.getLine, - sl.getColumn + sl.getValue.length + { if sl.getIsWrappedDoubleQuotes then 2 else 0 } - ) - case bl: BooleanLiteral => (bl.getLine, bl.getColumn + bl.getValue.toString.length) - case el: EmptyLiteral => (el.getLine, el.getColumn) - } - - val (line, column) = getEndPostion(ud.getAst) - - Position.File(path, (0, 0), (line, column), ud.getCodeOffset) - - def getDirectives = - ud.getAst match { - case usingDefs: UsingDefs => - usingDefs.getUsingDefs.asScala.toSeq - case _ => - Nil - } - - def nonEmpty: Boolean = !isEmpty - def isEmpty: Boolean = ud.getFlattenedMap.isEmpty - } -} diff --git a/modules/core/src/main/scala/scala/build/errors/NoValueProvidedError.scala b/modules/core/src/main/scala/scala/build/errors/NoValueProvidedError.scala deleted file mode 100644 index 5dbcc48a10..0000000000 --- a/modules/core/src/main/scala/scala/build/errors/NoValueProvidedError.scala +++ /dev/null @@ -1,7 +0,0 @@ -package scala.build.errors - -final class NoValueProvidedError(val key: String) extends BuildException( - s"Expected a value for directive $key", - // TODO - this seems like outdated thing - positions = Nil // I wish using_directives provided the key position… - ) diff --git a/modules/directives-parser/src/main/scala/scala/cli/parse/CommentExtractor.scala b/modules/directives-parser/src/main/scala/scala/cli/parse/CommentExtractor.scala new file mode 100644 index 0000000000..14fb1d21db --- /dev/null +++ b/modules/directives-parser/src/main/scala/scala/cli/parse/CommentExtractor.scala @@ -0,0 +1,177 @@ +package scala.cli.parse + +/** Represents a single `//> using ...` directive line extracted from a source file. */ +case class DirectiveLine( + /** The full text of the line, including the `//> ` prefix (no trailing newline). */ + content: String, + /** 0-indexed line number in the original source file. */ + lineNum: Int, + /** Absolute byte offset of the first character of this line in the source file. */ + lineStartOffset: Int +) + +/** Result of the comment extraction phase. */ +case class ExtractorResult( + directiveLines: Seq[DirectiveLine], + codeOffset: Int, + diagnostics: Seq[UsingDirectiveDiagnostic] +) + +/** Phase 1: scans a source file and extracts `//> using` directive lines. + * + * Rules: + * - Lines beginning with `#!` (shebang) are allowed only as the very first line. + * - Blank lines are allowed anywhere in the directive region. + * - Line comments (`//` not `//> `) are allowed and skipped. + * - Block comments (`/* ... */`, including multi-line) are allowed and skipped. + * - The first line that is none of the above marks the start of code (`codeOffset`). + * - Any `//> using` lines that appear after `codeOffset` are NOT included in the result, but a + * warning diagnostic is emitted for each one. + * - Directives inside block comments are NOT parsed. + */ +object CommentExtractor: + + private val UsingDirectiveRegex = """^//>\s+using(?:\s|$)""".r + + def extract(rawContent: Array[Char]): ExtractorResult = + val content = + if rawContent.nonEmpty && rawContent(0) == '\uFEFF' then rawContent.drop(1) + else rawContent + val length = content.length + val bomOffset = rawContent.length - content.length + val diagnostics = scala.collection.mutable.ArrayBuffer.empty[UsingDirectiveDiagnostic] + val directives = scala.collection.mutable.ArrayBuffer.empty[DirectiveLine] + + var offset = 0 + var lineNum = 0 + var codeStart = -1 // -1 means not yet found + + def currentLineText(lineStartOff: Int): String = + var end = lineStartOff + while end < length && content(end) != '\n' do end += 1 + new String(content, lineStartOff, end - lineStartOff) + + // Skip a block comment starting at `offset` (which points to `/` of `/*`). + // Returns the offset just after the closing `*/`, updating `lineNum`. + // Supports nested block comments to match the Scala compiler. + def skipBlockComment(startOff: Int, startLine: Int): (Int, Int) = + var off = startOff + 2 // skip `/*` + var ln = startLine + var depth = 1 + while off < length - 1 && depth > 0 do + if content(off) == '/' && content(off + 1) == '*' then + depth += 1 + off += 2 + else if content(off) == '*' && content(off + 1) == '/' then + depth -= 1 + off += 2 + else + if content(off) == '\n' then ln += 1 + off += 1 + if depth > 0 && off < length then + if content(off) == '\n' then ln += 1 + off += 1 + (off, ln) + + while offset < length && codeStart < 0 do + val lineStart = offset + + // Determine what kind of line this is (without consuming it yet) + val lineContent = currentLineText(lineStart) + val trimmed = lineContent.stripLeading() + + if trimmed.isEmpty then + // Blank line: skip + offset = lineStart + lineContent.length + if offset < length && content(offset) == '\n' then offset += 1 + lineNum += 1 + else if lineNum == 0 && trimmed.startsWith("#!") then + // Shebang: skip only on the very first line + offset = lineStart + lineContent.length + if offset < length && content(offset) == '\n' then offset += 1 + lineNum += 1 + else if trimmed.startsWith("/*") then + // Block comment: skip the whole comment, may span multiple lines + val commentStartOff = lineStart + lineContent.indexOf("/*") + val (afterComment, newLine) = + skipBlockComment(commentStartOff, lineNum) + // After the block comment, check if the rest of the line (if any) is blank + // Find the end of the current logical "section" that covers the block comment + // We need to advance `offset` and `lineNum` past the block comment. + // Also check if the block comment ends on the same line and there's more content. + offset = afterComment + lineNum = newLine + // Skip to end of the current line (in case there's trailing whitespace after `*/`) + while offset < length && content(offset) != '\n' do + val c = content(offset) + if c != ' ' && c != '\t' && c != '\r' then + // Non-blank, non-comment content after `*/` on the same line → code starts + codeStart = lineStart + offset += 1 + if codeStart < 0 then + if offset < length && content(offset) == '\n' then offset += 1 + lineNum += 1 + else if trimmed.startsWith("//") && !trimmed.startsWith("//>") then + // Line comment (not a directive): skip + offset = lineStart + lineContent.length + if offset < length && content(offset) == '\n' then offset += 1 + lineNum += 1 + else if trimmed.startsWith("//>") then + val withoutLeading = lineContent.dropWhile(c => c == ' ' || c == '\t') + val leadingLen = lineContent.length - withoutLeading.length + if withoutLeading.startsWith("//> using") then + val adjustedOffset = lineStart + leadingLen + bomOffset + directives += DirectiveLine(withoutLeading, lineNum, adjustedOffset) + offset = lineStart + lineContent.length + if offset < length && content(offset) == '\n' then offset += 1 + lineNum += 1 + else if UsingDirectiveRegex.findFirstIn(withoutLeading).isDefined then + val linePos = Some(Position(lineNum, leadingLen, lineStart + leadingLen + bomOffset)) + val msg = + s"Using directive must use the exact prefix `//> using`. Invalid prefix in: ${withoutLeading.trim}" + diagnostics += UsingDirectiveDiagnostic(msg, DiagnosticSeverity.Warning, linePos) + offset = lineStart + lineContent.length + if offset < length && content(offset) == '\n' then offset += 1 + lineNum += 1 + codeStart = offset + else + codeStart = lineStart + else + // First code line + codeStart = lineStart + + // If we never found code, codeOffset is end of file + val codeOffset = if codeStart >= 0 then codeStart else length + + // Continue scanning the rest of the file for post-code directives + if codeStart >= 0 then + offset = codeStart + var ln = lineNum + + while offset < length do + val lineStart = offset + val lineContent = currentLineText(lineStart) + val trimmed = lineContent.stripLeading() + + if trimmed.startsWith("/*") then + // Skip block comment + val commentStartOff = lineStart + lineContent.indexOf("/*") + val (afterComment, newLine) = skipBlockComment(commentStartOff, ln) + offset = afterComment + ln = newLine + while offset < length && content(offset) != '\n' do offset += 1 + if offset < length && content(offset) == '\n' then offset += 1 + ln += 1 + else + val linePos = Some(Position(ln, 0, lineStart + bomOffset)) + if trimmed.startsWith("//> using") then + val msg = s"Ignoring using directive found after Scala code: ${trimmed.trim}" + diagnostics += UsingDirectiveDiagnostic(msg, DiagnosticSeverity.Warning, linePos) + else if UsingDirectiveRegex.findFirstIn(trimmed).isDefined then + val msg = s"Ignoring using directive found after Scala code: ${trimmed.trim}" + diagnostics += UsingDirectiveDiagnostic(msg, DiagnosticSeverity.Warning, linePos) + offset = lineStart + lineContent.length + if offset < length && content(offset) == '\n' then offset += 1 + ln += 1 + + ExtractorResult(directives.toSeq, codeOffset + bomOffset, diagnostics.toSeq) diff --git a/modules/directives-parser/src/main/scala/scala/cli/parse/Diagnostics.scala b/modules/directives-parser/src/main/scala/scala/cli/parse/Diagnostics.scala new file mode 100644 index 0000000000..80de2b0bb7 --- /dev/null +++ b/modules/directives-parser/src/main/scala/scala/cli/parse/Diagnostics.scala @@ -0,0 +1,10 @@ +package scala.cli.parse + +enum DiagnosticSeverity: + case Error, Warning + +case class UsingDirectiveDiagnostic( + message: String, + severity: DiagnosticSeverity, + position: Option[Position] = None +) diff --git a/modules/directives-parser/src/main/scala/scala/cli/parse/DirectiveValue.scala b/modules/directives-parser/src/main/scala/scala/cli/parse/DirectiveValue.scala new file mode 100644 index 0000000000..30437804b1 --- /dev/null +++ b/modules/directives-parser/src/main/scala/scala/cli/parse/DirectiveValue.scala @@ -0,0 +1,37 @@ +package scala.cli.parse + +enum DirectiveValue: + /** A string value, either quoted (`"hello"`) or bare (`hello`). */ + case StringVal(value: String, isQuoted: Boolean, position: Position) + + /** A boolean literal (`true` or `false`). */ + case BoolVal(value: Boolean, position: Position) + + /** No value was provided after the key (treated as `true` downstream). */ + case EmptyVal(position: Position) + +object DirectiveValue: + extension (dv: DirectiveValue) + + def pos: Position = dv match + case StringVal(_, _, p) => p + case BoolVal(_, p) => p + case EmptyVal(p) => p + + /** Raw source text representation (e.g. `"hello"` with quotes for quoted strings). */ + def rawText: String = dv match + case StringVal(v, true, _) => s""""$v"""" + case StringVal(v, false, _) => v + case BoolVal(v, _) => v.toString + case EmptyVal(_) => "" + + /** The string content of the value (without quotes), or boolean/empty as string. */ + def stringValue: String = dv match + case StringVal(v, _, _) => v + case BoolVal(v, _) => v.toString + case EmptyVal(_) => "" + + /** Whether this value is a quoted string literal. */ + def isQuotedString: Boolean = dv match + case StringVal(_, true, _) => true + case _ => false diff --git a/modules/directives-parser/src/main/scala/scala/cli/parse/Lexer.scala b/modules/directives-parser/src/main/scala/scala/cli/parse/Lexer.scala new file mode 100644 index 0000000000..32322a413d --- /dev/null +++ b/modules/directives-parser/src/main/scala/scala/cli/parse/Lexer.scala @@ -0,0 +1,172 @@ +package scala.cli.parse + +/** Tokenizes the content of a single `//> using` directive line. + * + * The lexer receives the full line text (including the `//> ` prefix), the line number in the + * original file (0-indexed), and the absolute byte offset of the start of the line in the file. It + * produces a sequence of [[Token]]s with positions relative to the original file. + * + * Comma rule: a `,` is a [[Token.Comma]] separator only if immediately followed by whitespace or + * end of content. A `,` embedded in a non-whitespace sequence is part of the identifier/value + * token. This matches the behaviour of the original `using_directives` Java library. + */ +object Lexer: + + /** Tokenize a directive line into a flat [[Token]] sequence. + * + * @param lineText + * the full text of the line (e.g. `//> using dep com.lihaoyi::os-lib:0.11.4`) + * @param lineNum + * 0-indexed line number of this line in the original file + * @param lineStartOffset + * absolute byte offset of the first character of this line in the original file + * @return + * tokens produced from this line, ending with [[Token.Newline]] + */ + def tokenize(lineText: String, lineNum: Int, lineStartOffset: Int): Seq[Token] = + val chars = lineText.toArray + val length = chars.length + val buf = scala.collection.mutable.ArrayBuffer.empty[Token] + + def pos(col: Int): Position = + Position(lineNum, col, lineStartOffset + col) + + var col = 0 + + def skipDirectivePrefix(): Unit = + // Skip `//> ` (4 chars). The first character of useful content starts at col 4. + col = 4 + + def isWhitespace(c: Char): Boolean = + c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == '\f' + + skipDirectivePrefix() + + while col < length do + val c = chars(col) + if isWhitespace(c) then col += 1 + else if c == ',' && (col + 1 >= length || isWhitespace(chars(col + 1))) then + // Standalone comma: deprecated separator + buf += Token.Comma(pos(col)) + col += 1 + else if c == '`' then + // Backtick-quoted identifier: strip backticks, treat content as a bare Ident + val startCol = col + col += 1 + val sb = new StringBuilder + var closed = false + while col < length && !closed do + chars(col) match + case '`' => + closed = true + col += 1 + case other => + sb += other + col += 1 + val text = sb.toString + if !closed then + buf += Token.LexError("Unterminated backtick identifier", pos(startCol)) + else if text.isEmpty then + buf += Token.LexError("Empty backtick identifier", pos(startCol)) + else + buf += Token.Ident(text, pos(startCol)) + else if c == '"' then + // Double-quoted string literal + val startCol = col + col += 1 + val sb = new StringBuilder + var closed = false + while col < length && !closed do + chars(col) match + case '"' => + closed = true + col += 1 + case '\\' if col + 1 < length => + col += 1 + val escaped = chars(col) match + case 'n' => '\n' + case 't' => '\t' + case 'r' => '\r' + case '\\' => '\\' + case '"' => '"' + case 'u' if col + 4 < length => + val hex = lineText.substring(col + 1, col + 5) + col += 4 + try Integer.parseInt(hex, 16).toChar + catch + case _: NumberFormatException => + buf += Token.LexError( + s"Invalid unicode escape: \\u$hex", + pos(startCol) + ) + ' ' + case other => other + sb += escaped + col += 1 + case other => + sb += other + col += 1 + if !closed then + buf += Token.LexError("Unterminated string literal", pos(startCol)) + else + buf += Token.StringLit(sb.toString, pos(startCol)) + else + // Bare identifier / value: consume until whitespace or (comma + whitespace/end) + val startCol = col + val sb = new StringBuilder + var stop = false + while col < length && !stop do + val ch = chars(col) + if isWhitespace(ch) then stop = true + else if ch == '"' then + buf += Token.LexError( + "Whitespace is required between values — a quote cannot immediately follow another value.", + pos(col) + ) + stop = true + else if ch == ',' && (col + 1 >= length || isWhitespace(chars(col + 1))) then + // Comma followed by whitespace/end: stop here, don't consume the comma + stop = true + else + sb += ch + col += 1 + val text = sb.toString + if text.nonEmpty then + text match + case "using" => buf += Token.Using(pos(startCol)) + case "true" => buf += Token.BoolLit(true, pos(startCol)) + case "false" => buf += Token.BoolLit(false, pos(startCol)) + case _ => + // Check if this looks like a dotted key segment (may have internal dots) + // We emit Dot tokens for '.' characters between identifiers in key position; + // the Parser handles the distinction between key and value contexts. + // For simplicity, emit the whole bare token as Ident — the Parser splits on dots. + buf += Token.Ident(text, pos(startCol)) + + buf += Token.Newline(pos(length)) + buf.toSeq + + /** Tokenize a directive line and split dotted key parts into separate Ident + Dot tokens. + * + * This is used by the [[Parser]] to handle keys like `test.dep` where each segment is a separate + * Ident token joined by Dot tokens, while keeping values (which may contain `.`) as single Ident + * tokens. + * + * This method re-tokenizes an Ident that appears immediately after `using` (or after another + * Ident+Dot sequence) into its dotted components. + */ + def splitDottedIdent(ident: String, startPos: Position): Seq[Token] = + val parts = ident.split('.').toSeq + if parts.length <= 1 then Seq(Token.Ident(ident, startPos)) + else + val buf = scala.collection.mutable.ArrayBuffer.empty[Token] + var offset = 0 + parts.zipWithIndex.foreach: (part, i) => + val partPos = Position(startPos.line, startPos.column + offset, startPos.offset + offset) + buf += Token.Ident(part, partPos) + offset += part.length + if i < parts.length - 1 then + buf += + Token.Dot(Position(startPos.line, startPos.column + offset, startPos.offset + offset)) + offset += 1 + buf.toSeq diff --git a/modules/directives-parser/src/main/scala/scala/cli/parse/Parser.scala b/modules/directives-parser/src/main/scala/scala/cli/parse/Parser.scala new file mode 100644 index 0000000000..ee63ae7064 --- /dev/null +++ b/modules/directives-parser/src/main/scala/scala/cli/parse/Parser.scala @@ -0,0 +1,173 @@ +package scala.cli.parse + +import scala.annotation.tailrec + +/** Phase 3: recursive-descent parser that consumes a token stream and produces [[UsingDirective]] + * nodes. + * + * Grammar: + * {{{ + * Directives ::= { Directive } + * Directive ::= Using Key Values Newline + * Key ::= Ident (dotted keys are single Ident tokens, e.g. "test.dep") + * Values ::= { [Comma] Value } + * Value ::= StringLit | BoolLit | Ident + * }}} + * + * When Values is empty (key immediately followed by Newline), a single [[DirectiveValue.EmptyVal]] + * is produced. + * + * Error recovery: on unexpected token, a diagnostic is emitted and the parser skips to the next + * Newline token. + */ +object Parser: + + private def tokenKindName(t: Token): String = + t match + case _: Token.Using => "Using" + case _: Token.Ident => "Ident" + case _: Token.StringLit => "StringLit" + case _: Token.BoolLit => "BoolLit" + case _: Token.Dot => "Dot" + case _: Token.Comma => "Comma" + case _: Token.Newline => "Newline" + case _: Token.Eof => "Eof" + case _: Token.LexError => "LexError" + + def parse(tokens: Seq[Token]): (Seq[UsingDirective], Seq[UsingDirectiveDiagnostic]) = + val diagnostics = scala.collection.mutable.ArrayBuffer.empty[UsingDirectiveDiagnostic] + val directives = scala.collection.mutable.ArrayBuffer.empty[UsingDirective] + val arr = tokens.toIndexedSeq + var pos = 0 + + def current: Token = if pos < arr.length then arr(pos) else Token.Eof(Position(0, 0, 0)) + + def advance(): Unit = if pos < arr.length then pos += 1 + + def skipToNewline(): Unit = + @tailrec + def skipUntilBoundary(): Unit = + current match + case _: Token.Newline | _: Token.Eof => () + case _ => + advance() + skipUntilBoundary() + skipUntilBoundary() + current match + case _: Token.Newline => advance() + case _ => () + + def error(msg: String, p: Position): Unit = + diagnostics += UsingDirectiveDiagnostic(msg, DiagnosticSeverity.Error, Some(p)) + + def warn(msg: String, p: Position): Unit = + diagnostics += UsingDirectiveDiagnostic(msg, DiagnosticSeverity.Warning, Some(p)) + + def parseValues(): Seq[DirectiveValue] = + val values = scala.collection.mutable.ArrayBuffer.empty[DirectiveValue] + + @tailrec + def loop(): Seq[DirectiveValue] = + current match + case _: Token.Newline | _: Token.Eof => + values.toSeq + case Token.Comma(p) => + if values.isEmpty then + values += DirectiveValue.StringVal(",", isQuoted = false, p) + advance() + loop() + else + warn( + "Use of commas as separators is deprecated. Only whitespace is necessary.", + p + ) + advance() + loop() + case Token.StringLit(v, p) => + values += DirectiveValue.StringVal(v, isQuoted = true, p) + advance() + loop() + case Token.BoolLit(v, p) => + values += DirectiveValue.BoolVal(v, p) + advance() + loop() + case Token.Ident(v, p) => + if v.endsWith(",") then + warn( + s"Value '$v' ends with a comma — this is likely a typo from a double-comma sequence.", + p + ) + values += DirectiveValue.StringVal(v, isQuoted = false, p) + advance() + loop() + case Token.Using(p) => + values += DirectiveValue.StringVal("using", isQuoted = false, p) + advance() + loop() + case Token.LexError(msg, p) => + error(s"Lexer error: $msg", p) + advance() + loop() + case t => + error(s"Unexpected token in directive values: ${tokenKindName(t)}", t.pos) + skipToNewline() + values.toSeq + + loop() + + @tailrec + def parseDirectives(): Unit = + current match + case _: Token.Eof => + () + case Token.Newline(_) => + advance() + parseDirectives() + case Token.Using(usingPos) => + advance() + current match + case Token.Ident(keyText, keyPos) => + advance() + val values = parseValues() + val finalValues = + if values.isEmpty then + Seq(DirectiveValue.EmptyVal( + Position( + keyPos.line, + keyPos.column + keyText.length, + keyPos.offset + keyText.length + ) + )) + else values + directives += UsingDirective(keyText, finalValues, keyPos) + current match + case _: Token.Newline => advance() + case _ => () + parseDirectives() + + case Token.Newline(_) => + error("Expected a key after `using`", usingPos) + advance() + parseDirectives() + + case _: Token.Eof => + error("Expected a key after `using`", usingPos) + parseDirectives() + + case t => + error(s"Expected a key after `using`, found: ${tokenKindName(t)}", t.pos) + skipToNewline() + parseDirectives() + + case Token.LexError(msg, p) => + error(s"Lexer error: $msg", p) + skipToNewline() + parseDirectives() + + case t => + error(s"Unexpected token: ${tokenKindName(t)}", t.pos) + skipToNewline() + parseDirectives() + + parseDirectives() + (directives.toSeq, diagnostics.toSeq) diff --git a/modules/directives-parser/src/main/scala/scala/cli/parse/Position.scala b/modules/directives-parser/src/main/scala/scala/cli/parse/Position.scala new file mode 100644 index 0000000000..af079e2ca3 --- /dev/null +++ b/modules/directives-parser/src/main/scala/scala/cli/parse/Position.scala @@ -0,0 +1,4 @@ +package scala.cli.parse + +/** Position within a source file. Lines and columns are 0-based. */ +case class Position(line: Int, column: Int, offset: Int) diff --git a/modules/directives-parser/src/main/scala/scala/cli/parse/Token.scala b/modules/directives-parser/src/main/scala/scala/cli/parse/Token.scala new file mode 100644 index 0000000000..7b5ea0d341 --- /dev/null +++ b/modules/directives-parser/src/main/scala/scala/cli/parse/Token.scala @@ -0,0 +1,42 @@ +package scala.cli.parse + +enum Token: + /** The `using` keyword. */ + case Using(pos: Position) + + /** A bare identifier or value (non-quoted, non-whitespace sequence). */ + case Ident(value: String, pos: Position) + + /** A double-quoted string literal. */ + case StringLit(value: String, pos: Position) + + /** Boolean literal `true` or `false`. */ + case BoolLit(value: Boolean, pos: Position) + + /** Dot separator in dotted keys. */ + case Dot(pos: Position) + + /** Comma – accepted as a deprecated value separator. */ + case Comma(pos: Position) + + /** End of a directive line. */ + case Newline(pos: Position) + + /** End of token stream. */ + case Eof(pos: Position) + + /** A lexer error. */ + case LexError(message: String, pos: Position) + +object Token: + extension (t: Token) + def pos: Position = t match + case Using(p) => p + case Ident(_, p) => p + case StringLit(_, p) => p + case BoolLit(_, p) => p + case Dot(p) => p + case Comma(p) => p + case Newline(p) => p + case Eof(p) => p + case LexError(_, p) => p diff --git a/modules/directives-parser/src/main/scala/scala/cli/parse/UsingDirective.scala b/modules/directives-parser/src/main/scala/scala/cli/parse/UsingDirective.scala new file mode 100644 index 0000000000..0c09586862 --- /dev/null +++ b/modules/directives-parser/src/main/scala/scala/cli/parse/UsingDirective.scala @@ -0,0 +1,15 @@ +package scala.cli.parse + +/** A single parsed `//> using` directive. */ +case class UsingDirective( + key: String, + values: Seq[DirectiveValue], + keyPosition: Position +) + +/** The result of parsing a source file for using directives. */ +case class UsingDirectivesResult( + directives: Seq[UsingDirective], + codeOffset: Int, + diagnostics: Seq[UsingDirectiveDiagnostic] +) diff --git a/modules/directives-parser/src/main/scala/scala/cli/parse/UsingDirectivesParser.scala b/modules/directives-parser/src/main/scala/scala/cli/parse/UsingDirectivesParser.scala new file mode 100644 index 0000000000..f58407f800 --- /dev/null +++ b/modules/directives-parser/src/main/scala/scala/cli/parse/UsingDirectivesParser.scala @@ -0,0 +1,57 @@ +package scala.cli.parse + +/** Public entry point for the using directives parser. + * + * The pipeline is: + * 1. [[CommentExtractor.extract]] – scans the source and identifies `//> using` lines + * 2. [[Lexer.tokenize]] – tokenizes the content of each directive line + * 3. [[Parser.parse]] – converts the token stream into [[UsingDirective]] nodes + */ +object UsingDirectivesParser: + + /** Parse a source file and return all using directives along with diagnostics. + * + * @param content + * the raw content of the source file as a character array + * @return + * a [[UsingDirectivesResult]] with parsed directives, code offset, and diagnostics + */ + def parse(content: Array[Char]): UsingDirectivesResult = + val extracted = CommentExtractor.extract(content) + + val allTokens = scala.collection.mutable.ArrayBuffer.empty[Token] + for line <- extracted.directiveLines do + val tokens = Lexer.tokenize(line.content, line.lineNum, line.lineStartOffset) + allTokens ++= tokens + + allTokens += Token.Eof( + extracted.directiveLines.lastOption + .map(l => Position(l.lineNum, 0, l.lineStartOffset)) + .getOrElse(Position(0, 0, 0)) + ) + + val (directives, parserDiagnostics) = Parser.parse(allTokens.toSeq) + + val allDiagnostics = parserDiagnostics ++ extracted.diagnostics + + UsingDirectivesResult( + directives = directives, + codeOffset = extracted.codeOffset, + diagnostics = allDiagnostics + ) + + /** Tokenize a directive line. Exposed for testing. + * + * @param lineText + * the full line text including `//> ` prefix + * @param lineNum + * 0-indexed line number in the original file + * @param lineStartOffset + * absolute byte offset of the start of the line + */ + def tokenize(lineText: String, lineNum: Int, lineStartOffset: Int): Seq[Token] = + Lexer.tokenize(lineText, lineNum, lineStartOffset) + + /** Extract directive lines from source content. Exposed for testing. */ + def extractLines(content: Array[Char]): ExtractorResult = + CommentExtractor.extract(content) diff --git a/modules/directives-parser/src/test/scala/scala/cli/parse/CommentExtractorTests.scala b/modules/directives-parser/src/test/scala/scala/cli/parse/CommentExtractorTests.scala new file mode 100644 index 0000000000..319b5a8504 --- /dev/null +++ b/modules/directives-parser/src/test/scala/scala/cli/parse/CommentExtractorTests.scala @@ -0,0 +1,379 @@ +package scala.cli.parse + +import munit.FunSuite + +class CommentExtractorTests extends FunSuite: + + private def extract(src: String): ExtractorResult = + CommentExtractor.extract(src.toCharArray) + + test("simple directive line") { + val r = extract( + """//> using scala 3 + |""".stripMargin + ) + assertEquals(r.directiveLines.length, 1) + assertEquals(r.directiveLines.head.lineNum, 0) + assertEquals(r.codeOffset, 18) + } + + test("shebang is skipped on line 0") { + val src = + """#!/usr/bin/env scala + |//> using scala 3 + |""".stripMargin + val r = extract(src) + assertEquals(r.directiveLines.length, 1) + assertEquals(r.directiveLines.head.lineNum, 1) + } + + test("shebang after line 0 treated as code") { + val src = + """//> using scala 3 + |#!/usr/bin/env scala + |""".stripMargin + val r = extract(src) + assertEquals(r.directiveLines.length, 1) + assertEquals(r.codeOffset, 18) // shebang line is code + } + + test("blank lines do not affect directive region") { + val src = + """ + |//> using scala 3 + | + |val x = 1 + |""".stripMargin + val r = extract(src) + assertEquals(r.directiveLines.length, 1) + } + + test("line comments are skipped") { + val src = + """// a comment + |//> using scala 3 + |val x = 1 + |""".stripMargin + val r = extract(src) + assertEquals(r.directiveLines.length, 1) + } + + test("block comment before directive is skipped") { + val src = + """/* comment */ + |//> using scala 3 + |""".stripMargin + val r = extract(src) + assertEquals(r.directiveLines.length, 1) + } + + test("multi-line block comment is skipped") { + val src = + """/* + | * block + | */ + |//> using scala 3 + |""".stripMargin + val r = extract(src) + assertEquals(r.directiveLines.length, 1) + } + + test("codeOffset points to start of first code line") { + val src = + """//> using scala 3 + |val x = 1 + |""".stripMargin + val r = extract(src) + assertEquals(r.codeOffset, 18) // "//> using scala 3\n" is 18 chars + } + + test("no code -> codeOffset is file length") { + val src = + """//> using scala 3 + |""".stripMargin + val r = extract(src) + assertEquals(r.codeOffset, src.length) + } + + test("directive after code is ignored with warning") { + val src = + """val x = 1 + |//> using scala 3 + |""".stripMargin + val r = extract(src) + assertEquals(r.directiveLines.length, 0) + assert(r.diagnostics.exists(_.severity == DiagnosticSeverity.Warning)) + } + + test("multiple directives") { + val src = + """//> using scala 3 + |//> using dep com.lihaoyi::os-lib:0.11.4 + |""".stripMargin + val r = extract(src) + assertEquals(r.directiveLines.length, 2) + } + + test("lineStartOffset is correct") { + val src = + """//> using scala 3 + |//> using dep foo + |""".stripMargin + val r = extract(src) + assertEquals(r.directiveLines(0).lineStartOffset, 0) + assertEquals(r.directiveLines(1).lineStartOffset, 18) + } + + test("directives inside block comment are ignored") { + val src = + """/* //> using scala 3 */ + |val x = 1 + |""".stripMargin + val r = extract(src) + assertEquals(r.directiveLines.length, 0) + } + + test("`//> ` without `using` treated as code") { + val src = + """//> notUsing foo + |val x = 1 + |""".stripMargin + val r = extract(src) + assertEquals(r.directiveLines.length, 0) + assertEquals(r.codeOffset, 0) // first line is code + } + + test("ScalaDoc containing directive-like text is ignored") { + val src = + """/** ScalaDoc '//> using scala 3' */ + |val x = 1 + |""".stripMargin + val r = extract(src) + assertEquals(r.directiveLines.length, 0) + assertEquals(r.diagnostics.length, 0) + } + + test("multi-line ScalaDoc containing directive-like text is ignored") { + val src = + """/** + | * //> using scala 3 + | */ + |val x = 1 + |""".stripMargin + val r = extract(src) + assertEquals(r.directiveLines.length, 0) + assertEquals(r.diagnostics.length, 0) + } + + test("line comment containing embedded //> using text is skipped") { + val src = + """// line comment '//> using scala 3' + |val x = 1 + |""".stripMargin + val r = extract(src) + assertEquals(r.directiveLines.length, 0) + assertEquals(r.diagnostics.length, 0) + } + + test("issue 2382: all three comment types with directive-like text produce no errors") { + val src = + """// line comment '//> using ...' + |/* block comment '//> using ...' */ + |/** ScalaDoc '//> using ...' */ + |""".stripMargin + val r = extract(src) + assertEquals(r.directiveLines.length, 0) + assertEquals(r.diagnostics.length, 0) + } + + test("block comment with directive-like text followed by real directive") { + val src = + """/* //> using dep foo */ + |//> using scala 3 + |""".stripMargin + val r = extract(src) + assertEquals(r.directiveLines.length, 1) + assertEquals(r.directiveLines.head.lineNum, 1) + assertEquals(r.diagnostics.length, 0) + } + + test("package statement before directive makes directive post-code") { + val src = + """package x + |//> using scala 3 + |""".stripMargin + val r = extract(src) + assertEquals(r.directiveLines.length, 0) + assertEquals(r.codeOffset, 0) + assertEquals(r.diagnostics.length, 1) + assertEquals(r.diagnostics.head.severity, DiagnosticSeverity.Warning) + assert(r.diagnostics.head.message.contains("Ignoring")) + assert(r.diagnostics.head.message.contains("//> using scala 3")) + } + + test("multiple directives after code each produce a warning") { + val src = + """val x = 1 + |//> using scala 3 + |//> using dep foo + |""".stripMargin + val r = extract(src) + assertEquals(r.directiveLines.length, 0) + val ws = r.diagnostics.filter(_.severity == DiagnosticSeverity.Warning) + assertEquals(ws.length, 2) + assert(ws(0).message.contains("//> using scala 3")) + assert(ws(1).message.contains("//> using dep foo")) + } + + test("post-code directive warning has correct position") { + val src = + """val x = 1 + |//> using scala 3 + |""".stripMargin + val r = extract(src) + val w = r.diagnostics.head + assertEquals(w.position.map(_.line), Some(1)) + assertEquals(w.position.map(_.offset), Some(10)) + } + + test("mix of valid directives and post-code directives") { + val src = + """//> using scala 3 + |val x = 1 + |//> using dep foo + |""".stripMargin + val r = extract(src) + assertEquals(r.directiveLines.length, 1) + assertEquals(r.directiveLines.head.lineNum, 0) + val ws = r.diagnostics.filter(_.severity == DiagnosticSeverity.Warning) + assertEquals(ws.length, 1) + assert(ws.head.message.contains("//> using dep foo")) + } + + test("leading spaces before //> are stripped and directive is parsed correctly") { + val src = + """ //> using scala 3 + |""".stripMargin + val r = extract(src) + assertEquals(r.directiveLines.length, 1) + val dl = r.directiveLines.head + assertEquals(dl.content, "//> using scala 3") + assertEquals(dl.lineStartOffset, 2) + assertEquals(r.diagnostics.length, 0) + } + + test("leading tabs before //> are stripped and directive is parsed correctly") { + val src = + """ //> using scala 3 + |""".stripMargin + val r = extract(src) + assertEquals(r.directiveLines.length, 1) + assertEquals(r.directiveLines.head.content, "//> using scala 3") + assertEquals(r.diagnostics.length, 0) + } + + test("//>using without space is treated as code") { + val src = + """//>using scala 3 + |val x = 1 + |""".stripMargin + val r = extract(src) + assertEquals(r.directiveLines.length, 0) + assertEquals(r.codeOffset, 0) + } + + test("multiple spaces after //> emits a warning") { + val src = + """//> using scala 3 + |val x = 1 + |""".stripMargin + val r = extract(src) + assertEquals(r.directiveLines.length, 0) + val ws = r.diagnostics.filter(_.severity == DiagnosticSeverity.Warning) + assertEquals(ws.length, 1) + assert(ws.head.message.contains("exact prefix")) + assert(ws.head.message.contains("//> using scala 3")) + } + + test("tab after //> emits a warning") { + val src = + """//> using scala 3 + |val x = 1 + |""".stripMargin + val r = extract(src) + assertEquals(r.directiveLines.length, 0) + val ws = r.diagnostics.filter(_.severity == DiagnosticSeverity.Warning) + assertEquals(ws.length, 1) + assert(ws.head.message.contains("exact prefix")) + } + + test("CRLF line endings parse correctly") { + val src = "//> using scala 3\r\nval x = 1\r\n" + val r = extract(src) + assertEquals(r.directiveLines.length, 1) + assertEquals(r.directiveLines.head.content, "//> using scala 3\r") + assertEquals(r.codeOffset, 19) // includes \r\n + } + + test("UTF-8 BOM at start of file is stripped and directives are parsed") { + val src = + s"""\uFEFF//> using scala 3 + |""".stripMargin + val r = extract(src) + assertEquals(r.directiveLines.length, 1) + assertEquals(r.codeOffset, src.length) + } + + test("UTF-8 BOM before code does not suppress post-code directive warnings") { + val src = + s"""\uFEFFval x = 1 + |//> using scala 3 + |""".stripMargin + val r = extract(src) + assertEquals(r.directiveLines.length, 0) + assertEquals(r.codeOffset, 1) // BOM(1) + start of `val` + val ws = r.diagnostics.filter(_.severity == DiagnosticSeverity.Warning) + assertEquals(ws.length, 1) + assert(ws.head.message.contains("Ignoring")) + } + + // ----------------------------------------------------------------------- + // Edge Case 7: `*/` followed by directive on same line + // ----------------------------------------------------------------------- + + test("directive on same line after block comment closing is treated as code") { + val src = + """/* comment */ //> using scala 3 + |""".stripMargin + val r = extract(src) + assertEquals(r.directiveLines.length, 0) + assertEquals(r.codeOffset, 0) + } + + test("unclosed block comment swallows rest of file gracefully") { + val src = + """/* never closed + |//> using scala 3 + |""".stripMargin + val r = extract(src) + assertEquals(r.directiveLines.length, 0) + assertEquals(r.diagnostics.length, 0) + } + + test("nested block comments are handled correctly") { + val src = + """/* outer /* inner */ still comment */ + |//> using scala 3 + |""".stripMargin + val r = extract(src) + assertEquals(r.directiveLines.length, 1) + assertEquals(r.directiveLines.head.lineNum, 1) + } + + test("directive at end of file without trailing newline") { + val src = "//> using scala 3" + val r = extract(src) + assertEquals(r.directiveLines.length, 1) + assertEquals(r.codeOffset, src.length) + assertEquals(r.diagnostics.length, 0) + } diff --git a/modules/directives-parser/src/test/scala/scala/cli/parse/LexerTests.scala b/modules/directives-parser/src/test/scala/scala/cli/parse/LexerTests.scala new file mode 100644 index 0000000000..5649515130 --- /dev/null +++ b/modules/directives-parser/src/test/scala/scala/cli/parse/LexerTests.scala @@ -0,0 +1,169 @@ +package scala.cli.parse + +import munit.FunSuite + +class LexerTests extends FunSuite: + + private def lex(line: String, lineNum: Int = 0): Seq[Token] = + Lexer.tokenize(line, lineNum, 0) + + private def lexTokens(line: String): Seq[Token] = + lex(line).dropRight(1) // drop trailing Newline + + test("`using` is tokenized as Using, not Ident") { + val tokens = lexTokens("//> using scala 3") + assert( + tokens.head match { case _: Token.Using => true; case _ => false }, + s"got ${tokens.head}" + ) + } + + test("tokenize bare identifiers") { + val tokens = lexTokens("//> using dep foo") + val idents = tokens.collect { case Token.Ident(v, _) => v } + assertEquals(idents, Seq("dep", "foo")) + } + + test("tokenize dotted key as single Ident") { + val tokens = lexTokens("//> using test.dep munit") + val idents = tokens.collect { case Token.Ident(v, _) => v } + assertEquals(idents, Seq("test.dep", "munit")) + } + + test("tokenize quoted string") { + val tokens = lexTokens("""//> using dep "com.lihaoyi::os-lib:0.11.4"""") + val strLits = tokens.collect { case Token.StringLit(v, _) => v } + assertEquals(strLits, Seq("com.lihaoyi::os-lib:0.11.4")) + } + + test("tokenize boolean true") { + val tokens = lexTokens("//> using publish.doc true") + assert(tokens.exists { case Token.BoolLit(true, _) => true; case _ => false }) + } + + test("tokenize boolean false") { + val tokens = lexTokens("//> using publish.doc false") + assert(tokens.exists { case Token.BoolLit(false, _) => true; case _ => false }) + } + + test("comma followed by whitespace is a Comma token") { + val tokens = lexTokens("//> using dep a, b") + assert(tokens.exists { case _: Token.Comma => true; case _ => false }) + val idents = tokens.collect { case Token.Ident(v, _) => v } + assertEquals(idents.filter(_ != "dep"), Seq("a", "b")) + } + + test("comma embedded in value is NOT a separator") { + val tokens = lexTokens("//> using packaging.graalvmArgs --enable-url-protocols=http,https") + val idents = tokens.collect { case Token.Ident(v, _) => v } + assert(idents.contains("--enable-url-protocols=http,https"), s"idents=$idents") + assert(!tokens.exists { case _: Token.Comma => true; case _ => false }) + } + + test("unterminated string literal produces LexError") { + val tokens = lexTokens("""//> using dep "unterminated""") + assert(tokens.exists { case _: Token.LexError => true; case _ => false }, s"tokens=$tokens") + } + + test("string escape sequences") { + val tokens = lexTokens("""//> using dep "line1\nline2"""") + val strLits = tokens.collect { case Token.StringLit(v, _) => v } + assertEquals(strLits, Seq("line1\nline2")) + } + + test("column positions of key and value are correct") { + // "//> using dep foo" + // ^10 ^14 + val tokens = lexTokens("//> using dep foo") + val depPos = tokens.collectFirst { case Token.Ident("dep", p) => p }.get + assertEquals(depPos.column, 10) + val fooPos = tokens.collectFirst { case Token.Ident("foo", p) => p }.get + assertEquals(fooPos.column, 14) + } + + test("line position is preserved") { + val tokens = Lexer.tokenize("//> using dep foo", lineNum = 3, lineStartOffset = 100) + val depPos = tokens.collectFirst { case Token.Ident("dep", p) => p }.get + assertEquals(depPos.line, 3) + } + + test("trailing Newline token is always emitted") { + val tokens = lex("//> using scala 3") + assert(tokens.last match { case _: Token.Newline => true; case _ => false }) + } + + test("empty directive line still produces Newline") { + val tokens = lex("//> using") + assert(tokens.last match { case _: Token.Newline => true; case _ => false }) + } + + test("backtick-quoted identifier strips backticks") { + val tokens1 = lexTokens("//> using `native-gc`") + assertEquals(tokens1.collect { case Token.Ident(v, _) => v }, Seq("native-gc")) + + val tokens2 = lexTokens("//> using `native-mode`") + assertEquals(tokens2.collect { case Token.Ident(v, _) => v }, Seq("native-mode")) + } + + test("invalid unicode escape produces LexError instead of crashing") { + val tokens = lexTokens("//> using dep \"\\uZZZZ\"") + assert(tokens.exists { case _: Token.LexError => true; case _ => false }, s"tokens=$tokens") + val err = tokens.collectFirst { case Token.LexError(msg, _) => msg }.get + assert(err.contains("Invalid unicode escape"), s"error message: $err") + } + + test("empty backtick identifier produces LexError") { + val tokens = lexTokens("//> using `` foo") + assert(tokens.exists { case _: Token.LexError => true; case _ => false }, s"tokens=$tokens") + val err = tokens.collectFirst { case Token.LexError(msg, _) => msg }.get + assert(err.contains("Empty backtick identifier"), s"error message: $err") + } + + test("empty quoted string produces StringLit with empty value") { + val tokens = lexTokens("//> using dep \"\"") + val strs = tokens.collect { case Token.StringLit(v, _) => v } + assertEquals(strs, Seq("")) + } + + test("backtick-quoted value is parsed as bare Ident") { + val tokens = lexTokens("//> using dep `com.lihaoyi::os-lib:0.11.4`") + val idents = tokens.collect { case Token.Ident(v, _) => v } + assert(idents.contains("com.lihaoyi::os-lib:0.11.4"), s"idents=$idents") + } + + test("comma with no spaces produces single token") { + val tokens = lexTokens("//> using dep a,b") + val idents = tokens.collect { case Token.Ident(v, _) => v } + assertEquals(idents.filter(_ != "dep"), Seq("a,b")) + assert( + !tokens.exists { case _: Token.Comma => true; case _ => false }, + "should not produce Comma token" + ) + } + + test("space before comma but not after produces two tokens") { + val tokens = lexTokens("//> using dep a ,b") + val idents = tokens.collect { case Token.Ident(v, _) => v } + assertEquals(idents.filter(_ != "dep"), Seq("a", ",b")) + assert( + !tokens.exists { case _: Token.Comma => true; case _ => false }, + "should not produce Comma token" + ) + } + + test("double comma produces bare token ending with comma then Comma separator") { + val tokens = lexTokens("//> using dep a,, b") + val idents = tokens.collect { case Token.Ident(v, _) => v } + assertEquals(idents.filter(_ != "dep"), Seq("a,", "b")) + assert( + tokens.exists { case _: Token.Comma => true; case _ => false }, + "second comma should be a separator" + ) + } + + test("bare value touching quoted string emits LexError") { + val tokens = lexTokens("""//> using dep a,"b"""") + assert(tokens.exists { case _: Token.LexError => true; case _ => false }, s"tokens=$tokens") + val err = tokens.collectFirst { case Token.LexError(msg, _) => msg }.get + assert(err.contains("Whitespace is required"), s"error message: $err") + } diff --git a/modules/directives-parser/src/test/scala/scala/cli/parse/ParserTests.scala b/modules/directives-parser/src/test/scala/scala/cli/parse/ParserTests.scala new file mode 100644 index 0000000000..a68f84adaa --- /dev/null +++ b/modules/directives-parser/src/test/scala/scala/cli/parse/ParserTests.scala @@ -0,0 +1,508 @@ +package scala.cli.parse + +import munit.FunSuite + +class ParserTests extends FunSuite: + + private def parse(src: String): UsingDirectivesResult = + UsingDirectivesParser.parse(src.toCharArray) + + private def directives(src: String): Seq[UsingDirective] = + parse(src).directives + + private def warnings(src: String): Seq[UsingDirectiveDiagnostic] = + parse(src).diagnostics.filter(_.severity == DiagnosticSeverity.Warning) + + // ----------------------------------------------------------------------- + // Basic parsing + // ----------------------------------------------------------------------- + + test("parse simple directive key and value") { + val ds = directives( + """//> using scala 3 + |""".stripMargin + ) + assertEquals(ds.length, 1) + assertEquals(ds.head.key, "scala") + assertEquals(ds.head.values.length, 1) + val v = ds.head.values.head + v match + case sv: DirectiveValue.StringVal => assertEquals(sv.value, "3") + case _ => fail(s"expected StringVal, got $v") + } + + test("parse dotted key") { + val ds = directives( + """//> using test.dep munit::munit:1.0.0 + |""".stripMargin + ) + assertEquals(ds.head.key, "test.dep") + } + + test("parse multiple values") { + val ds = directives( + """//> using dep com.lihaoyi::os-lib:0.11.4 com.lihaoyi::upickle:3.1.0 + |""".stripMargin + ) + val values = ds.head.values + assertEquals(values.length, 2) + } + + test("parse quoted string value") { + val src = + """//> using scalacOption "-Xfatal-warnings" + |""".stripMargin + val ds = directives(src) + val v = ds.head.values.head + v match + case sv: DirectiveValue.StringVal => + assertEquals(sv.value, "-Xfatal-warnings") + assert(sv.isQuoted) + case _ => fail(s"expected StringVal, got $v") + } + + test("parse boolean true") { + val ds = directives( + """//> using publish.doc true + |""".stripMargin + ) + val v = ds.head.values.head + v match + case bv: DirectiveValue.BoolVal => assertEquals(bv.value, true) + case _ => fail(s"expected BoolVal, got $v") + } + + test("parse boolean false") { + val ds = directives( + """//> using publish.doc false + |""".stripMargin + ) + val v = ds.head.values.head + v match + case bv: DirectiveValue.BoolVal => assertEquals(bv.value, false) + case _ => fail(s"expected BoolVal, got $v") + } + + test("directive with no values produces EmptyVal") { + val ds = directives( + """//> using toolkit + |""".stripMargin + ) + assertEquals(ds.head.values.length, 1) + ds.head.values.head match + case _: DirectiveValue.EmptyVal => () + case v => fail(s"expected EmptyVal, got $v") + } + + test("multiple directives") { + val src = + """//> using scala 3 + |//> using dep foo + |""".stripMargin + val ds = directives(src) + assertEquals(ds.length, 2) + assertEquals(ds(0).key, "scala") + assertEquals(ds(1).key, "dep") + } + + // ----------------------------------------------------------------------- + // Comma deprecation + // ----------------------------------------------------------------------- + + test("comma separator emits deprecation warning") { + val ws = warnings( + """//> using dep a, b + |""".stripMargin + ) + assert(ws.nonEmpty, s"expected warnings, got none") + assert(ws.exists(_.message.contains("deprecated"))) + } + + test("each comma as separator emits its own deprecation warning") { + val ws = warnings( + """//> using dep a, b, c + |""".stripMargin + ) + val deprecationMsgs = ws.filter(_.message.contains("Use of commas as separators")) + assertEquals(deprecationMsgs.length, 2) + } + + test("comma separator still parses both values") { + val ds = directives( + """//> using dep a, b + |""".stripMargin + ) + assertEquals(ds.head.values.length, 2) + } + + test("embedded comma (no space) does NOT emit warning") { + val ws = warnings( + """//> using packaging.graalvmArgs --enable-url-protocols=http,https + |""".stripMargin + ) + assertEquals(ws.length, 0) + } + + // ----------------------------------------------------------------------- + // Position tracking + // ----------------------------------------------------------------------- + + test("positions are correct for single directive") { + // "//> using scala 3" + // 0123456789012345678 + // ^key ^value + val ds = directives( + """//> using scala 3 + |""".stripMargin + ) + val kp = ds.head.keyPosition + assertEquals(kp.line, 0) + assertEquals(kp.column, 10) + assertEquals(kp.offset, 10) + val vp = ds.head.values.head.pos + assertEquals(vp.column, 16) + } + + test("positions are correct for second directive on next line") { + val src = + """//> using scala 3 + |//> using dep foo + |""".stripMargin + val ds = directives(src) + val kp = ds(1).keyPosition + assertEquals(kp.line, 1) + // "//> using scala 3\n" is 18 chars, then "//> using " is 10 more = offset 28 + assertEquals(kp.offset, 28) + } + + // ----------------------------------------------------------------------- + // Diagnostics + // ----------------------------------------------------------------------- + + test("directive after code is ignored with warning") { + val src = + """val x = 1 + |//> using scala 3 + |""".stripMargin + val r = parse(src) + assertEquals(r.directives.length, 0) + assert(r.diagnostics.exists(_.severity == DiagnosticSeverity.Warning)) + } + + // ----------------------------------------------------------------------- + // codeOffset + // ----------------------------------------------------------------------- + + test("codeOffset after single directive") { + val src = + """//> using scala 3 + |val x = 1 + |""".stripMargin + val r = parse(src) + assertEquals(r.codeOffset, 18) + } + + test("codeOffset with no directives is 0") { + val src = + """val x = 1 + |""".stripMargin + val r = parse(src) + assertEquals(r.codeOffset, 0) + } + + test("codeOffset at end of file when only directives") { + val src = + """//> using scala 3 + |""".stripMargin + val r = parse(src) + assertEquals(r.codeOffset, src.length) + } + + // ----------------------------------------------------------------------- + // Edge cases + // ----------------------------------------------------------------------- + + test("empty source") { + val r = parse("") + assertEquals(r.directives.length, 0) + assertEquals(r.codeOffset, 0) + } + + test("source with only blank lines") { + val r = parse("\n\n\n") + assertEquals(r.directives.length, 0) + } + + test("value containing colon") { + val ds = directives( + """//> using dep com.lihaoyi::os-lib:0.11.4 + |""".stripMargin + ) + ds.head.values.head match + case sv: DirectiveValue.StringVal => + assertEquals(sv.value, "com.lihaoyi::os-lib:0.11.4") + case v => fail(s"expected StringVal, got $v") + } + + test("directive-like text in all comment types produces no directives or errors") { + val src = + """// line comment '//> using ...' + |/* block comment '//> using ...' */ + |/** ScalaDoc '//> using ...' */ + |""".stripMargin + val r = parse(src) + assertEquals(r.directives.length, 0) + assertEquals(r.diagnostics.length, 0) + } + + test("ScalaDoc before real directive does not interfere") { + val src = + """/** //> using dep fake */ + |//> using scala 3 + |val x = 1 + |""".stripMargin + val r = parse(src) + assertEquals(r.directives.length, 1) + assertEquals(r.directives.head.key, "scala") + assertEquals(r.diagnostics.length, 0) + } + + test("package before directives causes directives to be ignored with warning") { + val src = + """package x + |//> using scala 3.4.2 + |//> using dep foo + |@main def run = println(42) + |""".stripMargin + val r = parse(src) + assertEquals(r.directives.length, 0) + assertEquals(r.codeOffset, 0) + val ws = r.diagnostics.filter(_.severity == DiagnosticSeverity.Warning) + assertEquals(ws.length, 2) + assert(ws(0).message.contains("//> using scala 3.4.2")) + assert(ws(1).message.contains("//> using dep foo")) + } + + test("post-code directive warning includes directive text") { + val src = + """val x = 1 + |//> using scala 3 + |""".stripMargin + val ws = warnings(src) + assertEquals(ws.length, 1) + assert(ws.head.message.contains("Ignoring using directive found after Scala code")) + assert(ws.head.message.contains("//> using scala 3")) + } + + test("indented directive is parsed correctly end-to-end") { + val src = + """ //> using scala 3 + |val x = 1 + |""".stripMargin + val r = parse(src) + assertEquals(r.directives.length, 1) + assertEquals(r.directives.head.key, "scala") + val v = r.directives.head.values.head + v match + case sv: DirectiveValue.StringVal => assertEquals(sv.value, "3") + case other => fail(s"expected StringVal, got $other") + } + + test("inline block comment in directive line is not supported (key becomes /*)") { + val src = + """//> using /* comment */ dep foo + |""".stripMargin + val r = parse(src) + assertEquals(r.directives.length, 1) + assertEquals(r.directives.head.key, "/*") + } + + private def errors(src: String): Seq[UsingDirectiveDiagnostic] = + parse(src).diagnostics.filter(_.severity == DiagnosticSeverity.Error) + + test("`//> using` alone emits error about missing key") { + val errs = errors( + """//> using + |""".stripMargin + ) + assertEquals(errs.length, 1) + assert(errs.head.message.contains("Expected a key after `using`")) + } + + test("`using` as key emits error") { + val errs = errors( + """//> using using foo + |""".stripMargin + ) + assertEquals(errs.length, 1) + assert(errs.head.message.contains("Expected a key after `using`")) + assert(errs.head.message.contains("Using")) + } + + test("`true` as key emits error") { + val errs = errors( + """//> using true foo + |""".stripMargin + ) + assertEquals(errs.length, 1) + assert(errs.head.message.contains("Expected a key after `using`")) + assert(errs.head.message.contains("BoolLit")) + } + + test("`false` as key emits error") { + val errs = errors( + """//> using false foo + |""".stripMargin + ) + assertEquals(errs.length, 1) + assert(errs.head.message.contains("Expected a key after `using`")) + } + + test("key with trailing dot is accepted by parser") { + val ds = directives( + """//> using foo. bar + |""".stripMargin + ) + assertEquals(ds.length, 1) + assertEquals(ds.head.key, "foo.") + } + + test("key with leading dot is accepted by parser") { + val ds = directives( + """//> using .foo bar + |""".stripMargin + ) + assertEquals(ds.length, 1) + assertEquals(ds.head.key, ".foo") + } + + test("comma with no spaces is a single value, no warnings") { + val src = + """//> using dep a,b + |""".stripMargin + val ds = directives(src) + assertEquals(ds.head.values.length, 1) + ds.head.values.head match + case sv: DirectiveValue.StringVal => assertEquals(sv.value, "a,b") + case v => fail(s"expected StringVal, got $v") + assertEquals(warnings(src).length, 0) + } + + test("space before comma but not after produces two values") { + val src = + """//> using dep a ,b + |""".stripMargin + val ds = directives(src) + assertEquals(ds.head.values.length, 2) + val vs = ds.head.values.collect { case sv: DirectiveValue.StringVal => sv.value } + assertEquals(vs, Seq("a", ",b")) + assertEquals(warnings(src).length, 0) + } + + test("trailing comma produces 2 values and 2 deprecation warnings") { + val src = + """//> using dep a, b, + |""".stripMargin + val ds = directives(src) + assertEquals(ds.head.values.length, 2) + val vs = ds.head.values.collect { case sv: DirectiveValue.StringVal => sv.value } + assertEquals(vs, Seq("a", "b")) + val ws = warnings(src) + assertEquals(ws.length, 2) + assert(ws.forall(_.message.contains("deprecated"))) + } + + test("lone comma is treated as a literal value") { + val src = + """//> using dep , + |""".stripMargin + val ds = directives(src) + assertEquals(ds.head.values.length, 1) + ds.head.values.head match + case sv: DirectiveValue.StringVal => assertEquals(sv.value, ",") + case v => fail(s"expected StringVal(','), got $v") + assertEquals(warnings(src).length, 0) + } + + test("double comma emits warning about value ending with comma") { + val src = + """//> using dep a,, b + |""".stripMargin + val ds = directives(src) + val vs = ds.head.values.collect { case sv: DirectiveValue.StringVal => sv.value } + assertEquals(vs, Seq("a,", "b")) + val ws = warnings(src) + assert(ws.exists(_.message.contains("ends with a comma")), s"warnings=$ws") + assert(ws.exists(_.message.contains("deprecated")), s"warnings=$ws") + } + + test("comma inside quoted string is preserved literally") { + val src = + """//> using dep "a,b" + |""".stripMargin + val ds = directives(src) + assertEquals(ds.head.values.length, 1) + ds.head.values.head match + case sv: DirectiveValue.StringVal => + assertEquals(sv.value, "a,b") + assert(sv.isQuoted) + case v => fail(s"expected quoted StringVal, got $v") + assertEquals(warnings(src).length, 0) + } + + test("coursier dep with ,url= is a single value") { + val src = + """//> using dep tabby:tabby:0.2.3,url=https://example.com/tabby.jar + |""".stripMargin + val ds = directives(src) + assertEquals(ds.head.values.length, 1) + ds.head.values.head match + case sv: DirectiveValue.StringVal => + assertEquals(sv.value, "tabby:tabby:0.2.3,url=https://example.com/tabby.jar") + case v => fail(s"expected StringVal, got $v") + assertEquals(warnings(src).length, 0) + } + + test("coursier dep with ,exclude= is a single value") { + val src = + """//> using dep com.lihaoyi::os-lib:0.11.3,exclude=com.lihaoyi%%geny + |""".stripMargin + val ds = directives(src) + assertEquals(ds.head.values.length, 1) + ds.head.values.head match + case sv: DirectiveValue.StringVal => + assertEquals(sv.value, "com.lihaoyi::os-lib:0.11.3,exclude=com.lihaoyi%%geny") + case v => fail(s"expected StringVal, got $v") + assertEquals(warnings(src).length, 0) + } + + test("mixed commas — some as separators, some embedded") { + val src = + """//> using dep a,b, c,d + |""".stripMargin + val ds = directives(src) + val vs = ds.head.values.collect { case sv: DirectiveValue.StringVal => sv.value } + assertEquals(vs, Seq("a,b", "c,d")) + val ws = warnings(src) + assertEquals(ws.count(_.message.contains("deprecated")), 1) + } + + test("quoted values with comma separator between them") { + val src = + """//> using javacOpt "source", "1.8" + |""".stripMargin + val ds = directives(src) + assertEquals(ds.head.values.length, 2) + val vs = ds.head.values.collect { case sv: DirectiveValue.StringVal => sv.value } + assertEquals(vs, Seq("source", "1.8")) + assertEquals(warnings(src).count(_.message.contains("deprecated")), 1) + } + + test("bare value touching quoted string emits error") { + val src = + """//> using dep a,"b" + |""".stripMargin + val r = parse(src) + val errs = r.diagnostics.filter(_.severity == DiagnosticSeverity.Error) + assert(errs.exists(_.message.contains("Whitespace is required")), s"errors=$errs") + } diff --git a/modules/directives/src/main/scala/scala/build/directives/DirectiveValueParser.scala b/modules/directives/src/main/scala/scala/build/directives/DirectiveValueParser.scala index f314fdf69f..3440a5b54e 100644 --- a/modules/directives/src/main/scala/scala/build/directives/DirectiveValueParser.scala +++ b/modules/directives/src/main/scala/scala/build/directives/DirectiveValueParser.scala @@ -1,7 +1,5 @@ package scala.build.directives -import com.virtuslab.using_directives.custom.model.{BooleanValue, EmptyValue, StringValue, Value} - import scala.build.errors.{ BuildException, CompositeBuildException, @@ -13,11 +11,12 @@ import scala.build.errors.{ import scala.build.preprocessing.ScopePath import scala.build.preprocessing.directives.DirectiveUtil import scala.build.{Position, Positioned} +import scala.cli.parse.DirectiveValue abstract class DirectiveValueParser[+T] { def parse( key: String, - values: Seq[Value[?]], + values: Seq[DirectiveValue], scopePath: ScopePath, path: Either[String, os.Path] ): Either[BuildException, T] @@ -32,7 +31,7 @@ object DirectiveValueParser { extends DirectiveValueParser[U] { def parse( key: String, - values: Seq[Value[?]], + values: Seq[DirectiveValue], scopePath: ScopePath, path: Either[String, os.Path] ): Either[BuildException, U] = @@ -42,14 +41,14 @@ object DirectiveValueParser { abstract class DirectiveSingleValueParser[+T] extends DirectiveValueParser[T] { def parseValue( key: String, - value: Value[?], + value: DirectiveValue, cwd: ScopePath, path: Either[String, os.Path] ): Either[BuildException, T] final def parse( key: String, - values: Seq[Value[?]], + values: Seq[DirectiveValue], scopePath: ScopePath, path: Either[String, os.Path] ): Either[BuildException, T] = @@ -79,33 +78,36 @@ object DirectiveValueParser { } } - extension (value: Value[?]) { + extension (value: DirectiveValue) { def isEmpty: Boolean = value match { - case _: EmptyValue => true - case _ => false + case _: DirectiveValue.EmptyVal => true + case _ => false } def isString: Boolean = value match { - case _: StringValue => true - case _ => false + case _: DirectiveValue.StringVal => true + case _ => false } + def asString: Option[String] = value match { - case s: StringValue => Some(s.get()) - case _ => None + case s: DirectiveValue.StringVal => Some(s.value) + case _ => None } + def isBoolean: Boolean = value match { - case _: BooleanValue => true - case _ => false + case _: DirectiveValue.BoolVal => true + case _ => false } + def asBoolean: Option[Boolean] = value match { - case s: BooleanValue => Some(s.get()) - case _ => None + case b: DirectiveValue.BoolVal => Some(b.value) + case _ => None } def position(path: Either[String, os.Path]): Position = @@ -127,7 +129,7 @@ object DirectiveValueParser { case values0 => Left( new MalformedDirectiveError( - s"Unexpected values ${values0.map(_.toString).mkString(", ")}", + s"Unexpected values ${values0.map(_.rawText).mkString(", ")}", values0.map(_.position(path)) ) ) @@ -141,7 +143,7 @@ object DirectiveValueParser { new MalformedDirectiveError( message = s"""Encountered an error for the $key using directive. - |Expected a string, got '${value.getRelatedASTNode.toString}'""".stripMargin, + |Expected a string, got '${value.rawText}'""".stripMargin, positions = Seq(pos) ) }.map(DirectiveSpecialSyntax.handlingSpecialPathSyntax(_, path)) @@ -154,7 +156,7 @@ object DirectiveValueParser { val pos = value.position(path) new MalformedDirectiveError( s"""Encountered an error for the $key using directive. - |Expected a string value, got '${value.getRelatedASTNode.toString}'""".stripMargin, + |Expected a string value, got '${value.rawText}'""".stripMargin, Seq(pos) ) } diff --git a/modules/directives/src/main/scala/scala/build/directives/ScopedValue.scala b/modules/directives/src/main/scala/scala/build/directives/ScopedValue.scala index 990d23008f..f9be7f36b2 100644 --- a/modules/directives/src/main/scala/scala/build/directives/ScopedValue.scala +++ b/modules/directives/src/main/scala/scala/build/directives/ScopedValue.scala @@ -1,11 +1,9 @@ package scala.build.preprocessing.directives -import com.virtuslab.using_directives.custom.model.Value - import scala.build.Positioned import scala.build.preprocessing.ScopePath -case class ScopedValue[T <: Value[?]]( +case class ScopedValue[T]( positioned: Positioned[String], maybeScopePath: Option[ScopePath] = None ) diff --git a/modules/directives/src/main/scala/scala/build/errors/SingleValueExpectedError.scala b/modules/directives/src/main/scala/scala/build/errors/SingleValueExpectedError.scala index 8f9ffb046c..87af626d48 100644 --- a/modules/directives/src/main/scala/scala/build/errors/SingleValueExpectedError.scala +++ b/modules/directives/src/main/scala/scala/build/errors/SingleValueExpectedError.scala @@ -7,7 +7,7 @@ final class SingleValueExpectedError( val path: Either[String, os.Path] ) extends BuildException( s"Expected a single value for directive ${directive.key} " + - s"(got ${directive.values.length} values: ${directive.values.map(_.get().toString).mkString(", ")})", + s"(got ${directive.values.length} values: ${directive.values.map(_.stringValue).mkString(", ")})", positions = DirectiveUtil.positions(directive.values, path) ) { assert(directive.stringValuesCount > 1) diff --git a/modules/directives/src/main/scala/scala/build/preprocessing/directives/DirectiveHandler.scala b/modules/directives/src/main/scala/scala/build/preprocessing/directives/DirectiveHandler.scala index 30faecea7f..1e2067561e 100644 --- a/modules/directives/src/main/scala/scala/build/preprocessing/directives/DirectiveHandler.scala +++ b/modules/directives/src/main/scala/scala/build/preprocessing/directives/DirectiveHandler.scala @@ -2,10 +2,8 @@ package scala.build.preprocessing.directives import java.util.Locale import scala.build.Logger -import scala.build.Ops.* import scala.build.directives.* -import scala.build.errors.{BuildException, CompositeBuildException, UnexpectedDirectiveError} -import scala.build.preprocessing.Scoped +import scala.build.errors.{BuildException, UnexpectedDirectiveError} import scala.cli.commands.SpecificationLevel import scala.quoted.* @@ -288,46 +286,21 @@ object DirectiveHandler { (scopedDirective, logger) => '{ - if (${ cond('{ $scopedDirective.directive.key }) }) { - val valuesByScope = $scopedDirective.directive.values.groupBy(_.getScope) - .toVector - .map { - case (scopeOrNull, values) => - (Option(scopeOrNull), values) - } - .sortBy(_._1.getOrElse("")) - valuesByScope - .map { - case (scopeOpt, _) => - $parser.parse( - $scopedDirective.directive.key, - $scopedDirective.directive.values, - $scopedDirective.cwd, - $scopedDirective.maybePath - ).map { r => - scopeOpt -> ${ - genNew(List(newArgs.updated(idx, 'r).map(_.asTerm))) - .asExprOf[T] - } - } - } - .sequence - .left.map(CompositeBuildException(_)) - .map { v => - val mainOpt = v.collectFirst { - case (None, t) => t - } - val scoped = v.collect { - case (Some(scopeStr), t) => - // FIXME os.RelPath(…) might fail - Scoped( - $scopedDirective.cwd / os.RelPath(scopeStr), - t - ) - } - ProcessedDirective(mainOpt, scoped) - } - } + if (${ cond('{ $scopedDirective.directive.key }) }) + $parser.parse( + $scopedDirective.directive.key, + $scopedDirective.directive.values, + $scopedDirective.cwd, + $scopedDirective.maybePath + ).map { r => + ProcessedDirective( + Some(${ + genNew(List(newArgs.updated(idx, 'r).map(_.asTerm))) + .asExprOf[T] + }), + Nil + ) + } else ${ elseCase0(scopedDirective, logger) } } diff --git a/modules/directives/src/main/scala/scala/build/preprocessing/directives/DirectiveUtil.scala b/modules/directives/src/main/scala/scala/build/preprocessing/directives/DirectiveUtil.scala index 5380238d4a..5470434c62 100644 --- a/modules/directives/src/main/scala/scala/build/preprocessing/directives/DirectiveUtil.scala +++ b/modules/directives/src/main/scala/scala/build/preprocessing/directives/DirectiveUtil.scala @@ -1,48 +1,36 @@ package scala.build.preprocessing.directives -import com.virtuslab.using_directives.custom.model.{BooleanValue, StringValue, Value} -import com.virtuslab.using_directives.custom.utils.ast.StringLiteral import dependency.AnyDependency import dependency.parser.DependencyParser import scala.build.Ops.* import scala.build.errors.{BuildException, CompositeBuildException, DependencyFormatError} -import scala.build.preprocessing.ScopePath import scala.build.{Position, Positioned} +import scala.cli.parse.DirectiveValue object DirectiveUtil { - def isWrappedInDoubleQuotes(v: Value[?]): Boolean = - v match { - case stringValue: StringValue => - stringValue.getRelatedASTNode match { - case literal: StringLiteral => literal.getIsWrappedDoubleQuotes() - case _ => false - } - case _ => false - } - def position(v: Value[?], path: Either[String, os.Path]): Position.File = { - val skipQuotes: Boolean = isWrappedInDoubleQuotes(v) - val line = v.getRelatedASTNode.getPosition.getLine - val column = v.getRelatedASTNode.getPosition.getColumn + (if (skipQuotes) 1 else 0) - val endLinePos = column + v.toString.length - Position.File(path, (line, column), (line, endLinePos)) - } + def isWrappedInDoubleQuotes(v: DirectiveValue): Boolean = + v.isQuotedString - def scope(v: Value[?], cwd: ScopePath): Option[ScopePath] = - Option(v.getScope).map((p: String) => cwd / os.RelPath(p)) + def position(v: DirectiveValue, path: Either[String, os.Path]): Position.File = { + val p = v.pos + val skipQuotes = v.isQuotedString + val column = p.column + (if skipQuotes then 1 else 0) + val endCol = column + v.stringValue.length + Position.File(path, (p.line, column), (p.line, endCol)) + } def concatAllValues( scopedDirective: ScopedDirective ): Seq[String] = scopedDirective.directive.values.collect: - case v: StringValue => v.get - case v: BooleanValue => v.get.toString + case v: DirectiveValue.StringVal => v.value + case v: DirectiveValue.BoolVal => v.value.toString - def positions(values: Seq[Value[?]], path: Either[String, os.Path]): Seq[Position] = + def positions(values: Seq[DirectiveValue], path: Either[String, os.Path]): Seq[Position] = values.map { v => - val line = v.getRelatedASTNode.getPosition.getLine - val column = v.getRelatedASTNode.getPosition.getColumn - Position.File(path, (line, column), (line, column)) + val p = v.pos + Position.File(path, (p.line, p.column), (p.line, p.column)) } extension (deps: List[Positioned[String]]) { diff --git a/modules/directives/src/main/scala/scala/build/preprocessing/directives/Exclude.scala b/modules/directives/src/main/scala/scala/build/preprocessing/directives/Exclude.scala index 4dcc8990c3..9ea816dd56 100644 --- a/modules/directives/src/main/scala/scala/build/preprocessing/directives/Exclude.scala +++ b/modules/directives/src/main/scala/scala/build/preprocessing/directives/Exclude.scala @@ -1,7 +1,6 @@ package scala.build.preprocessing.directives import scala.build.EitherCps.either -import scala.build.Ops.* import scala.build.Positioned import scala.build.directives.* import scala.build.errors.BuildException diff --git a/modules/directives/src/main/scala/scala/build/preprocessing/directives/StrictDirective.scala b/modules/directives/src/main/scala/scala/build/preprocessing/directives/StrictDirective.scala index e3eff4a320..4c93df238b 100644 --- a/modules/directives/src/main/scala/scala/build/preprocessing/directives/StrictDirective.scala +++ b/modules/directives/src/main/scala/scala/build/preprocessing/directives/StrictDirective.scala @@ -1,8 +1,7 @@ package scala.build.preprocessing.directives -import com.virtuslab.using_directives.custom.model.{EmptyValue, Value} - import scala.build.Position +import scala.cli.parse.DirectiveValue /** Represents a directive with a key and a sequence of values. * @@ -12,22 +11,28 @@ import scala.build.Position * the sequence of values of the directive * @param startColumn * the column where the key of the directive starts + * @param startLine + * the line where the key of the directive starts */ - case class StrictDirective( key: String, - values: Seq[Value[?]], + values: Seq[DirectiveValue], startColumn: Int = 0, startLine: Int = 0 ) { + + /** Same style as the legacy `using_directives` values' `toString` (unquoted string content), so + * user-facing messages (e.g. experimental-feature warnings) match integration-test expectations. + */ override def toString: String = { - val suffix = if validValues.isEmpty then "" else s" ${validValues.mkString(" ")}" + val suffix = + if validValues.isEmpty then "" else s" ${validValues.map(_.stringValue).mkString(" ")}" s"//> using $key$suffix" } - private def validValues = values.filter { - case _: EmptyValue => false - case _ => true + private def validValues: Seq[DirectiveValue] = values.filter { + case _: DirectiveValue.EmptyVal => false + case _ => true } /** Checks whether the directive with the sequence of values will fit into the given column limit, @@ -36,20 +41,16 @@ case class StrictDirective( * distinct values, each with a single value. */ def explodeToStringsWithColLimit(colLimit: Int = 100): Seq[String] = { - val validValues = values.filter { - case _: EmptyValue => false - case _ => true - } - + val validVals = validValues val usingKeyString = s"//> using $key" - if (validValues.isEmpty) + if (validVals.isEmpty) Seq(usingKeyString) else { - val distinctValuesStrings = validValues - .map { - case s if s.toString.exists(_.isWhitespace) => s"\"$s\"" - case s => s.toString + val distinctValuesStrings = validVals + .map { v => + val s = v.stringValue + if s.exists(_.isWhitespace) then s"\"$s\"" else s } .distinct .sorted @@ -63,17 +64,18 @@ case class StrictDirective( def stringValuesCount: Int = validValues.length - def toStringValues: Seq[String] = validValues.map(_.toString) + def toStringValues: Seq[String] = validValues.map(_.stringValue) def position(path: Either[String, os.Path]): Position.File = values.lastOption .map { v => - val position = DirectiveUtil.position(v, path) + val p = v.pos v match - case _: EmptyValue => position.startPos - case v if DirectiveUtil.isWrappedInDoubleQuotes(v) => - position.endPos._1 -> (position.endPos._2 + 1) - case _ => position.endPos + case _: DirectiveValue.EmptyVal => (p.line, p.column) + case bv: DirectiveValue.BoolVal => (p.line, p.column + bv.value.toString.length) + case sv: DirectiveValue.StringVal if sv.isQuoted => + (p.line, p.column + sv.value.length + 2) + case sv: DirectiveValue.StringVal => (p.line, p.column + sv.value.length) }.map { (line, endColumn) => Position.File( path, @@ -81,5 +83,4 @@ case class StrictDirective( (line, endColumn) ) }.getOrElse(Position.File(path, (0, 0), (0, 0))) - } diff --git a/modules/integration/src/test/scala/scala/cli/integration/PublishSetupTests.scala b/modules/integration/src/test/scala/scala/cli/integration/PublishSetupTests.scala index 0e70156967..304d9f1923 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/PublishSetupTests.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/PublishSetupTests.scala @@ -1,12 +1,10 @@ package scala.cli.integration import com.eed3si9n.expecty.Expecty.expect -import com.virtuslab.using_directives.UsingDirectivesProcessor -import com.virtuslab.using_directives.reporter.ConsoleReporter import org.eclipse.jgit.api.Git import org.eclipse.jgit.transport.URIish -import scala.jdk.CollectionConverters.* +import scala.cli.parse.{DirectiveValue, UsingDirectivesParser} import scala.util.Properties import scala.util.matching.Regex @@ -70,21 +68,13 @@ class PublishSetupTests extends ScalaCliSuite { } private def directives(content: String): Map[String, Seq[String]] = { - val reporter = new ConsoleReporter - val processor = new UsingDirectivesProcessor(reporter) - - val usedDirectives = processor - .extract(content.toCharArray) - .asScala - .head - - usedDirectives - .getFlattenedMap - .asScala - .toSeq - .map { - case (k, l) => - (k.getPath.asScala.mkString("."), l.asScala.toSeq.map(_.toString)) + val result = UsingDirectivesParser.parse(content.toCharArray) + result.directives + .map { d => + d.key -> d.values.collect { + case DirectiveValue.StringVal(v, _, _) => v + case DirectiveValue.BoolVal(v, _) => v.toString + } } .toMap } diff --git a/project/deps/package.mill b/project/deps/package.mill index 2502b82a9f..307ad28ca5 100644 --- a/project/deps/package.mill +++ b/project/deps/package.mill @@ -281,7 +281,6 @@ object Deps { def toolkitTest = mvn"org.scala-lang:toolkit-test:$toolkitVersion" val typelevelToolkitVersion = "0.1.29" def typelevelToolkit = mvn"org.typelevel:toolkit:$typelevelToolkitVersion" - def usingDirectives = mvn"org.virtuslab:using_directives:1.1.4" // Lives at https://github.com/VirtusLab/no-crc32-zip-input-stream, see #865 // This provides a ZipInputStream that doesn't verify CRC32 checksums, that users // can enable by setting SCALA_CLI_VENDORED_ZIS=true in the environment, to workaround From cd57741386926e3e7470c4366c8896974f540854 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Fri, 17 Apr 2026 17:15:01 +0200 Subject: [PATCH 61/64] Fix docs & add tests for `repositories.mirrors` & `repositories.default` (#4235) --- .../scala/cli/integration/ConfigTests.scala | 276 ++++++++++++++++++ website/docs/guides/power/repositories.md | 82 +++++- 2 files changed, 353 insertions(+), 5 deletions(-) diff --git a/modules/integration/src/test/scala/scala/cli/integration/ConfigTests.scala b/modules/integration/src/test/scala/scala/cli/integration/ConfigTests.scala index 4647fb31ab..18de94272b 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/ConfigTests.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/ConfigTests.scala @@ -562,6 +562,282 @@ class ConfigTests extends ScalaCliSuite { } } + test("repository mirrors") { + val testOrg = "test-org" + val testName = "the-messages" + val testVersion = "0.1.2" + val inputs = TestInputs( + os.rel / "messages" / "Messages.scala" -> + """package messages + | + |object Messages { + | def hello(name: String): String = + | s"Hello $name" + |} + |""".stripMargin, + os.rel / "hello" / "Hello.scala" -> + s"""//> using dep $testOrg::$testName:$testVersion + |import messages.Messages + |object Hello { + | def main(args: Array[String]): Unit = + | println(Messages.hello("World")) + |} + |""".stripMargin + ) + inputs.fromRoot { root => + val configFile = { + val dir = root / "conf" + os.makeDir.all(dir, if (Properties.isWin) null else "rwx------") + dir / "config.json" + } + val extraEnv = Map("SCALA_CLI_CONFIG" -> configFile.toString) + val repoPath = root / "the-repo" + + os.proc( + TestUtil.cli, + "--power", + "publish", + "--publish-repo", + repoPath.toNIO.toUri.toASCIIString, + "messages", + "--organization", + testOrg, + "--name", + testName, + "--project-version", + testVersion + ).call(cwd = root, stdin = os.Inherit, stdout = os.Inherit, env = extraEnv) + + val fakeRepoUrl = "https://google.com/maven" + val repoUri = repoPath.toNIO.toUri.toASCIIString.stripSuffix("/") + os.proc( + TestUtil.cli, + "--power", + "config", + "repositories.mirrors", + s"$repoUri=$fakeRepoUrl" + ).call(cwd = root, stdin = os.Inherit, stdout = os.Inherit, env = extraEnv) + + val res = os.proc( + TestUtil.cli, + "run", + "--repository", + fakeRepoUrl, + "hello" + ).call(cwd = root, env = extraEnv) + expect(res.out.trim() == "Hello World") + } + } + + test("repository mirrors with maven wildcard") { + val testOrg = "test-org" + val testName = "the-messages" + val testVersion = "0.1.5" + val inputs = TestInputs( + os.rel / "messages" / "Messages.scala" -> + """package messages + | + |object Messages { + | def hello(name: String): String = + | s"Hello $name" + |} + |""".stripMargin, + os.rel / "hello" / "Hello.scala" -> + s"""//> using dep $testOrg::$testName:$testVersion + |import messages.Messages + |object Hello { + | def main(args: Array[String]): Unit = + | println(Messages.hello("World")) + |} + |""".stripMargin, + os.rel / "warmup.sc" -> "println(42)" + ) + inputs.fromRoot { root => + val configFile = { + val dir = root / "conf" + os.makeDir.all(dir, if (Properties.isWin) null else "rwx------") + dir / "config.json" + } + val extraEnv = Map("SCALA_CLI_CONFIG" -> configFile.toString) + val repoPath = root / "the-repo" + val warmCache = root / "warm-cache" + val freshCache = root / "fresh-cache" + + os.proc(TestUtil.cli, "compile", "--server=false", "warmup.sc") + .call(cwd = root, env = extraEnv ++ Map("COURSIER_CACHE" -> warmCache.toString)) + + val centralCacheDir = warmCache / "https" / "repo1.maven.org" / "maven2" + os.copy(centralCacheDir, repoPath, createFolders = true) + + os.proc( + TestUtil.cli, + "--power", + "publish", + "--publish-repo", + repoPath.toNIO.toUri.toASCIIString, + "messages", + "--organization", + testOrg, + "--name", + testName, + "--project-version", + testVersion + ).call(cwd = root, stdin = os.Inherit, stdout = os.Inherit, env = extraEnv) + + val repoUri = repoPath.toNIO.toUri.toASCIIString.stripSuffix("/") + os.proc( + TestUtil.cli, + "--power", + "config", + "repositories.mirrors", + s"maven:$repoUri=*" + ).call(cwd = root, stdin = os.Inherit, stdout = os.Inherit, env = extraEnv) + + val res = os.proc(TestUtil.cli, "run", "--server=false", "hello") + .call(cwd = root, env = extraEnv ++ Map("COURSIER_CACHE" -> freshCache.toString)) + expect(res.out.trim() == "Hello World") + } + } + + test("default repositories from config") { + val testOrg = "test-org" + val testName = "the-messages" + val testVersion = "0.1.3" + val inputs = TestInputs( + os.rel / "messages" / "Messages.scala" -> + """package messages + | + |object Messages { + | def hello(name: String): String = + | s"Hello $name" + |} + |""".stripMargin, + os.rel / "hello" / "Hello.scala" -> + s"""//> using dep $testOrg::$testName:$testVersion + |import messages.Messages + |object Hello { + | def main(args: Array[String]): Unit = + | println(Messages.hello("World")) + |} + |""".stripMargin + ) + inputs.fromRoot { root => + val configFile = { + val dir = root / "conf" + os.makeDir.all(dir, if (Properties.isWin) null else "rwx------") + dir / "config.json" + } + val extraEnv = Map("SCALA_CLI_CONFIG" -> configFile.toString) + val repoPath = root / "the-repo" + + os.proc( + TestUtil.cli, + "--power", + "publish", + "--publish-repo", + repoPath.toNIO.toUri.toASCIIString, + "messages", + "--organization", + testOrg, + "--name", + testName, + "--project-version", + testVersion + ).call(cwd = root, stdin = os.Inherit, stdout = os.Inherit, env = extraEnv) + + val repoUri = repoPath.toNIO.toUri.toASCIIString.stripSuffix("/") + os.proc( + TestUtil.cli, + "--power", + "config", + "repositories.default", + "https://repo1.maven.org/maven2", + repoUri + ).call(cwd = root, stdin = os.Inherit, stdout = os.Inherit, env = extraEnv) + + val res = os.proc( + TestUtil.cli, + "run", + "hello" + ).call(cwd = root, env = extraEnv) + expect(res.out.trim() == "Hello World") + } + } + + test("default repositories and mirrors together") { + val testOrg = "test-org" + val testName = "the-messages" + val testVersion = "0.1.4" + val inputs = TestInputs( + os.rel / "messages" / "Messages.scala" -> + """package messages + | + |object Messages { + | def hello(name: String): String = + | s"Hello $name" + |} + |""".stripMargin, + os.rel / "hello" / "Hello.scala" -> + s"""//> using dep $testOrg::$testName:$testVersion + |import messages.Messages + |object Hello { + | def main(args: Array[String]): Unit = + | println(Messages.hello("World")) + |} + |""".stripMargin + ) + inputs.fromRoot { root => + val configFile = { + val dir = root / "conf" + os.makeDir.all(dir, if (Properties.isWin) null else "rwx------") + dir / "config.json" + } + val extraEnv = Map("SCALA_CLI_CONFIG" -> configFile.toString) + val repoPath = root / "the-repo" + + os.proc( + TestUtil.cli, + "--power", + "publish", + "--publish-repo", + repoPath.toNIO.toUri.toASCIIString, + "messages", + "--organization", + testOrg, + "--name", + testName, + "--project-version", + testVersion + ).call(cwd = root, stdin = os.Inherit, stdout = os.Inherit, env = extraEnv) + + val fakeRepoUrl = "https://fake-repo.test/maven" + os.proc( + TestUtil.cli, + "--power", + "config", + "repositories.default", + "https://repo1.maven.org/maven2", + fakeRepoUrl + ).call(cwd = root, stdin = os.Inherit, stdout = os.Inherit, env = extraEnv) + + val repoUri = repoPath.toNIO.toUri.toASCIIString.stripSuffix("/") + os.proc( + TestUtil.cli, + "--power", + "config", + "repositories.mirrors", + s"$repoUri=$fakeRepoUrl" + ).call(cwd = root, stdin = os.Inherit, stdout = os.Inherit, env = extraEnv) + + val res = os.proc( + TestUtil.cli, + "run", + "hello" + ).call(cwd = root, env = extraEnv) + expect(res.out.trim() == "Hello World") + } + } + for { offlineSetting <- Seq(true, false) prefillCache <- if (offlineSetting) Seq(true, false) else Seq(false) diff --git a/website/docs/guides/power/repositories.md b/website/docs/guides/power/repositories.md index 532a5628d6..d929945b39 100644 --- a/website/docs/guides/power/repositories.md +++ b/website/docs/guides/power/repositories.md @@ -51,20 +51,92 @@ Repository authentication is also supported and there are a couple ways of using ## Default repositories -You can override the default Coursier repositories globally by invoking: +You can override the default Coursier repositories (Maven Central and local Ivy) globally by invoking: ```bash ignore scala-cli --power config repositories.default https://first-repo.company.com https://second-repo.company.com ``` +This **replaces** the built-in default repositories for all dependency resolution, including compiler +and library downloads. Repositories added via `--repository` or `//> using repository` are not +affected — those remain additive on top of the configured defaults. + +To include Maven Central alongside your internal repository, list both: +```bash ignore +scala-cli --power config repositories.default https://repo1.maven.org/maven2 https://nexus.company.com/repository/maven-public +``` + +Alternatively, you can set default repositories via the `COURSIER_REPOSITORIES` environment variable +(pipe-separated) or the `coursier.repositories` Java property. + ## Mirrors If you're fine directly downloading artifacts from the internet, but would rather have some -repositories requests go through a repository of yours, configure mirror repositories, like +repository requests go through a repository of yours, configure mirror repositories. + +To redirect a specific repository URL to your mirror: +```bash ignore +scala-cli --power config repositories.mirrors https://repository.company.com/maven=https://repo1.maven.org/maven2 +``` + +To have **all** requests to any Maven repository go through a repository of yours: +```bash ignore +scala-cli --power config repositories.mirrors maven:https://repository.company.com/maven=* +``` + +Mirrors apply to all Coursier-based operations including compiler downloads, dependency +resolution, and Bloop server downloads. JVM downloads (via `--jvm`) are not affected, as they +use a separate download mechanism. + +### Mirror string syntax + +The format is `destination=source` — the mirror target comes first, then the original repository: + +| Syntax | Type | Effect | +|---|---|---| +| `=` | Maven | Redirects `` to `` | +| `maven:=*` | Maven | Redirects **all** Maven repository URLs to `` | +| `tree:=` | Tree | Redirects URLs starting with `` prefix to `` | + +### Alternative mirror configuration + +Mirrors can also be configured via: +- The `COURSIER_MIRRORS` environment variable, pointing to a `mirror.properties` file +- The `coursier.mirrors` Java property (e.g. via `scala-cli --power config java.properties -Dcoursier.mirrors=/path/to/mirror.properties`) +- A `mirror.properties` file in the Coursier configuration directory + +The `mirror.properties` file format: +```properties +central.from=https://repo1.maven.org/maven2 +central.to=https://repository.company.com/maven +central.type=maven +``` + +### Using mirrors with default repositories + +If both `repositories.default` and `repositories.mirrors` are configured, default repositories +are resolved first (which repos to query), then mirrors transform the URLs (where requests +actually go). For example: +```bash ignore +scala-cli --power config repositories.default https://repo1.maven.org/maven2 +scala-cli --power config repositories.mirrors maven:https://nexus.company.com/maven=* +``` +This uses Maven Central as the default repository, but routes all requests through `nexus.company.com`. + +## Corporate / air-gapped environments + +For environments behind a firewall where Maven Central is not directly reachable: + +1. **Override default repositories** to point at your internal repository: +```bash ignore +scala-cli --power config repositories.default https://nexus.company.com/repository/maven-public +``` + +2. Or **set up a mirror** to redirect Maven Central: ```bash ignore -scala-cli --power config repositories.mirrors https://repo1.maven.org/maven2=https://repository.company.com/maven +scala-cli --power config repositories.mirrors maven:https://nexus.company.com/repository/maven-public=* ``` -To have all requests to a Maven repository go through a repository of yours, do +3. If your repository requires authentication, add credentials: ```bash ignore -scala-cli --power config repositories.mirrors maven:*=https://repository.company.com/maven +scala-cli --power config repositories.credentials nexus.company.com value:username value:password ``` \ No newline at end of file From 0790dbecf7ffbcc441e834a6b45b00b38214174d Mon Sep 17 00:00:00 2001 From: lostflydev Date: Thu, 2 Apr 2026 08:44:21 +0500 Subject: [PATCH 62/64] Add WASM support: --wasm flag with Node.js and Deno runtimes Implements scala-cli issue #3316: integrate WebAssembly with Scala CLI. - `--wasm` CLI flag and `//> using wasm` directive to enable WASM output - `--wasm-runtime ` option and `//> using wasmRuntime` directive Supported values: node (default), deno - `--deno-version`, `--wasmtime-version`, `--wasmer-version` options and corresponding directives for pinning runtime versions - **Node.js** (default): runs Scala.js WASM output with `--experimental-wasm-exnref` flag, requires Node.js >= 22 - **Deno**: runs Scala.js WASM output --- build.mill | 2 + .../scala/scala/build/internal/Runner.scala | 146 ++++- .../DirectivesPreprocessingUtils.scala | 3 +- .../scala/cli/commands/fix/BuiltInRules.scala | 1 + .../scala/scala/cli/commands/run/Run.scala | 515 +++++++++++------- .../cli/commands/shared/HelpGroups.scala | 3 +- .../cli/commands/shared/SharedOptions.scala | 41 +- .../cli/commands/shared/WasmOptions.scala | 32 ++ .../cli/internal/WasmRuntimeDownloader.scala | 104 ++++ .../build/errors/DenoNotFoundError.scala | 5 + .../errors/UnsupportedWasmRuntimeError.scala | 3 + .../build/preprocessing/directives/Wasm.scala | 52 ++ .../RunScalaJsTestDefinitions.scala | 277 +++++++++- .../scala/build/options/BuildOptions.scala | 1 + .../scala/build/options/WasmOptions.scala | 26 + .../scala/build/options/WasmRuntime.scala | 54 ++ website/docs/reference/cli-options.md | 26 + website/docs/reference/directives.md | 21 + 18 files changed, 1085 insertions(+), 227 deletions(-) create mode 100644 modules/cli/src/main/scala/scala/cli/commands/shared/WasmOptions.scala create mode 100644 modules/cli/src/main/scala/scala/cli/internal/WasmRuntimeDownloader.scala create mode 100644 modules/core/src/main/scala/scala/build/errors/DenoNotFoundError.scala create mode 100644 modules/core/src/main/scala/scala/build/errors/UnsupportedWasmRuntimeError.scala create mode 100644 modules/directives/src/main/scala/scala/build/preprocessing/directives/Wasm.scala create mode 100644 modules/options/src/main/scala/scala/build/options/WasmOptions.scala create mode 100644 modules/options/src/main/scala/scala/build/options/WasmRuntime.scala diff --git a/build.mill b/build.mill index e43785f8f7..87d819031d 100644 --- a/build.mill +++ b/build.mill @@ -533,6 +533,8 @@ trait Core extends ScalaCliCrossSbtModule | def toolkitVersionForNative04 = "${Deps.toolkitVersionForNative04}" | def toolkitVersionForNative05 = "${Deps.toolkitVersionForNative05}" | + | def defaultDenoVersion = "2.1.4" + | | def typelevelOrganization = "${Deps.typelevelToolkit.dep.module.organization.value}" | def typelevelToolkitDefaultVersion = "${Deps.typelevelToolkitVersion}" | def typelevelToolkitMaxScalaNative = "${Deps.Versions.maxScalaNativeForTypelevelToolkit}" diff --git a/modules/build/src/main/scala/scala/build/internal/Runner.scala b/modules/build/src/main/scala/scala/build/internal/Runner.scala index 47d70c89a4..fb4bec255b 100644 --- a/modules/build/src/main/scala/scala/build/internal/Runner.scala +++ b/modules/build/src/main/scala/scala/build/internal/Runner.scala @@ -189,6 +189,60 @@ object Runner { run(command, logger, cwd = cwd, extraEnv = extraEnv) } + // Detects the major version of Node.js on PATH; cached for the JVM lifetime (lazy val). + // Returns None if node is not found or version cannot be parsed. + private lazy val nodeMajorVersion: Option[Int] = + try { + val process = new ProcessBuilder("node", "--version") + .redirectErrorStream(true) + .start() + val output = new String(process.getInputStream.readAllBytes()).trim + process.waitFor() + // Node version format: "v22.5.0" -> extract 22 + if (output.startsWith("v")) + output.drop(1).takeWhile(_.isDigit) match { + case s if s.nonEmpty => Some(s.toInt) + case _ => None + } + else None + } + catch { + case _: Exception => None + } + + // Node 24+ (V8 13+) has wasm-exnref enabled by default; older versions need --experimental-wasm-exnref. + private def nodeNeedsWasmFlag: Boolean = + nodeMajorVersion.forall(_ < 24) // true if unknown or < 24 + + // Detects the major version of Deno on PATH; cached for the JVM lifetime (lazy val). + // Returns None if deno is not found or version cannot be parsed. + private lazy val denoMajorVersion: Option[Int] = + try { + val process = new ProcessBuilder("deno", "--version") + .redirectErrorStream(true) + .start() + val output = new String(process.getInputStream.readAllBytes()).trim + process.waitFor() + // Deno version format: "deno 2.1.0 (release, aarch64-apple-darwin)\nv8 13.x\ntypescript 5.x" + // Extract major from first line + val firstLine = output.linesIterator.nextOption().getOrElse("") + val versionStr = firstLine.stripPrefix("deno ").takeWhile(c => c.isDigit || c == '.') + versionStr.takeWhile(_.isDigit) match { + case s if s.nonEmpty => Some(s.toInt) + case _ => None + } + } + catch { + case _: Exception => None + } + + // Deno 2.x+ bundles V8 13+ which has wasm-exnref enabled by default; no flag needed. + private def denoNeedsWasmFlag: Boolean = + denoMajorVersion.flatMap { major => + if (major >= 2) Some(false) // Deno 2.x+ has V8 13+ with wasm-exnref by default + else Some(true) + }.getOrElse(true) // true if unknown + private def endsWithCaseInsensitive(s: String, suffix: String): Boolean = s.length >= suffix.length && s.regionMatches(true, s.length - suffix.length, suffix, 0, suffix.length) @@ -221,11 +275,13 @@ object Runner { def jsCommand( entrypoint: File, args: Seq[String], - jsDom: Boolean = false + jsDom: Boolean = false, + emitWasm: Boolean = false ): Seq[String] = { - val nodePath = findInPath("node").fold("node")(_.toString) - val command = Seq(nodePath, entrypoint.getAbsolutePath) ++ args + val nodePath = findInPath("node").fold("node")(_.toString) + val nodeFlags = if (emitWasm && nodeNeedsWasmFlag) List("--experimental-wasm-exnref") else Nil + val command = Seq(nodePath) ++ nodeFlags ++ Seq(entrypoint.getAbsolutePath) ++ args if (jsDom) // FIXME We'd need to replicate what JSDOMNodeJSEnv does under-the-hood to get the command in that case. @@ -242,14 +298,16 @@ object Runner { allowExecve: Boolean = false, jsDom: Boolean = false, sourceMap: Boolean = false, - esModule: Boolean = false + esModule: Boolean = false, + emitWasm: Boolean = false ): Either[BuildException, Process] = either { val nodePath: String = value(findInPath("node") .map(_.toString) .toRight(NodeNotFoundError())) + val nodeFlags = if (emitWasm && nodeNeedsWasmFlag) List("--experimental-wasm-exnref") else Nil if !jsDom && allowExecve && Execve.available() then { - val command = Seq(nodePath, entrypoint.getAbsolutePath) ++ args + val command = Seq(nodePath) ++ nodeFlags ++ Seq(entrypoint.getAbsolutePath) ++ args logger.log( s"Running ${command.mkString(" ")}", @@ -265,12 +323,25 @@ object Runner { ) sys.error("should not happen") } + else if (emitWasm) { + // For WASM mode with ES modules, run node directly instead of NodeJSEnv. + // NodeJSEnv's stdin piping with "-" doesn't work with Input.ESModule. + val command = Seq(nodePath) ++ nodeFlags ++ Seq(entrypoint.getAbsolutePath) ++ args + + logger.log( + s"Running ${command.mkString(" ")}", + " Running" + System.lineSeparator() + + command.iterator.map(_ + System.lineSeparator()).mkString + ) + + new ProcessBuilder(command: _*).inheritIO().start() + } else { val nodeArgs = // Scala.js runs apps by piping JS to node. // If we need to pass arguments, we must first make the piped input explicit // with "-", and we pass the user's arguments after that. - if args.isEmpty then Nil else "-" :: args.toList + nodeFlags ++ (if args.isEmpty then Nil else "-" :: args.toList) val envJs = if jsDom then new JSDOMNodeJSEnv( @@ -307,6 +378,69 @@ object Runner { } } + def denoCommand( + entrypoint: File, + args: Seq[String], + denoPathOpt: Option[String] = None + ): Seq[String] = { + val denoPath = denoPathOpt.getOrElse(findInPath("deno").fold("deno")(_.toString)) + val denoFlags = Seq("run", "--allow-read") + Seq(denoPath) ++ denoFlags ++ Seq(entrypoint.getAbsolutePath) ++ args + } + + def runDeno( + entrypoint: File, + args: Seq[String], + logger: Logger, + allowExecve: Boolean = false, + emitWasm: Boolean = false, + denoPathOpt: Option[String] = None + ): Either[BuildException, Process] = either { + val denoPath: String = denoPathOpt.getOrElse { + value(findInPath("deno") + .map(_.toString) + .toRight(DenoNotFoundError())) + } + val denoFlags = Seq("run", "--allow-read") + val extraEnv = + if (emitWasm && denoNeedsWasmFlag) Map("DENO_V8_FLAGS" -> "--experimental-wasm-exnref") + else Map.empty + + if (allowExecve && Execve.available()) { + val command = Seq(denoPath) ++ denoFlags ++ Seq(entrypoint.getAbsolutePath) ++ args + + logger.log( + s"Running ${command.mkString(" ")}", + " Running" + System.lineSeparator() + + command.iterator.map(_ + System.lineSeparator()).mkString + ) + + logger.debug("execve available") + Execve.execve( + command.head, + "deno" +: command.tail.toArray, + (sys.env ++ extraEnv).toArray.sorted.map { case (k, v) => s"$k=$v" } + ) + sys.error("should not happen") + } + else { + val command = Seq(denoPath) ++ denoFlags ++ Seq(entrypoint.getAbsolutePath) ++ args + + logger.log( + s"Running ${command.mkString(" ")}", + " Running" + System.lineSeparator() + + command.iterator.map(_ + System.lineSeparator()).mkString + ) + + val builder = new ProcessBuilder(command*) + .inheritIO() + val env = builder.environment() + for ((k, v) <- extraEnv) + env.put(k, v) + builder.start() + } + } + def runNative( launcher: File, args: Seq[String], diff --git a/modules/build/src/main/scala/scala/build/preprocessing/directives/DirectivesPreprocessingUtils.scala b/modules/build/src/main/scala/scala/build/preprocessing/directives/DirectivesPreprocessingUtils.scala index 5b439b07fc..dfacd593fa 100644 --- a/modules/build/src/main/scala/scala/build/preprocessing/directives/DirectivesPreprocessingUtils.scala +++ b/modules/build/src/main/scala/scala/build/preprocessing/directives/DirectivesPreprocessingUtils.scala @@ -32,7 +32,8 @@ object DirectivesPreprocessingUtils { directives.ScalaVersion.handler, directives.Sources.handler, directives.Watching.handler, - directives.Tests.handler + directives.Tests.handler, + directives.Wasm.handler ).map(_.mapE(_.buildOptions)) val usingDirectiveWithReqsHandlers diff --git a/modules/cli/src/main/scala/scala/cli/commands/fix/BuiltInRules.scala b/modules/cli/src/main/scala/scala/cli/commands/fix/BuiltInRules.scala index 5c34b3368b..a2f92d2e42 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/fix/BuiltInRules.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/fix/BuiltInRules.scala @@ -387,6 +387,7 @@ object BuiltInRules extends CommandHelpers { JavaHome.handler.keys, ScalaNative.handler.keys, ScalaJs.handler.keys, + Wasm.handler.keys, ScalacOptions.handler.keys, JavaOptions.handler.keys, JavacOptions.handler.keys, diff --git a/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala b/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala index e22e6bedc8..af484d123d 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala @@ -12,13 +12,13 @@ import java.util.concurrent.atomic.AtomicReference import scala.build.* import scala.build.EitherCps.{either, value} import scala.build.Ops.* -import scala.build.errors.{BuildException, CompositeBuildException} +import scala.build.errors.{BuildException, CompositeBuildException, UnsupportedWasmRuntimeError} import scala.build.input.* import scala.build.internal.{Constants, Runner, ScalaJsLinkerConfig} import scala.build.internals.ConsoleUtils.ScalaCliConsole import scala.build.internals.ConsoleUtils.ScalaCliConsole.warnPrefix import scala.build.internals.EnvVar -import scala.build.options.{BuildOptions, JavaOpt, PackageType, Platform, Scope} +import scala.build.options.{BuildOptions, JavaOpt, PackageType, Platform, Scope, WasmRuntime} import scala.cli.CurrentParams import scala.cli.commands.package0.Package import scala.cli.commands.setupide.SetupIde @@ -28,7 +28,7 @@ import scala.cli.commands.util.BuildCommandHelpers.* import scala.cli.commands.util.{BuildCommandHelpers, RunHadoop, RunSpark} import scala.cli.commands.{CommandUtils, ScalaCommand, SpecificationLevel, WatchUtil} import scala.cli.config.Keys -import scala.cli.internal.ProcUtil +import scala.cli.internal.{ProcUtil, WasmRuntimeDownloader} import scala.cli.packaging.Library.fullClassPathMaybeAsJar import scala.cli.util.ArgHelpers.* import scala.cli.util.ConfigDbUtils @@ -474,229 +474,330 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers { if shouldLogCrossInfo then logger.debug(s"Running build for ${crossBuildParams.asString}") val build = builds.head either { - build.options.platform.value match { - case Platform.JS => - val esModule = - build.options.scalaJsOptions.moduleKindStr.exists(m => m == "es" || m == "esmodule") - - val linkerConfig = builds.head.options.scalaJsOptions.linkerConfig(logger) - val jsDest = { - val delete = scratchDirOpt.isEmpty - scratchDirOpt.foreach(os.makeDir.all(_)) - os.temp( - dir = scratchDirOpt.orNull, - prefix = "main", - suffix = if esModule then ".mjs" else ".js", - deleteOnExit = delete - ) + val wasmOpts = build.options.wasmOptions + + // Check if WASM mode is requested + if wasmOpts.enabled then { + val runtime = wasmOpts.runtime + + if runtime.isJsBased then { + // JS-based WASM path - uses Scala.js WASM with JavaScript helpers (Node.js or Deno) + val esModule = true // WASM backend uses ES modules + scratchDirOpt.foreach(os.makeDir.all(_)) + val jsDest = os.temp( + dir = scratchDirOpt.orNull, + prefix = "main", + suffix = ".mjs", + deleteOnExit = scratchDirOpt.isEmpty + ) + + // Resolve Deno binary: check PATH first, download if needed + val denoPathOpt: Option[String] = runtime match { + case WasmRuntime.Deno => + val denoCmd = value(WasmRuntimeDownloader.denoCommand( + wasmOpts.finalDenoVersion, + build.options.archiveCache, + logger + )) + Some(denoCmd.head) + case _ => None } - val res = - Package.linkJs( - builds = builds, - dest = jsDest, - mainClassOpt = Some(mainClass), - addTestInitializer = false, - config = linkerConfig, - fullOpt = value(build.options.scalaJsOptions.fullOpt), - noOpt = build.options.scalaJsOptions.noOpt.getOrElse(false), - logger = logger, - scratchDirOpt = scratchDirOpt - ).map { outputPath => - val jsDom = build.options.scalaJsOptions.dom.getOrElse(false) - if showCommand then Left(Runner.jsCommand(outputPath.toIO, args, jsDom = jsDom)) - else { - val process = value { - Runner.runJs( - outputPath.toIO, - args, - logger, - allowExecve = effectiveAllowExecve, - jsDom = jsDom, - sourceMap = build.options.scalaJsOptions.emitSourceMaps, - esModule = esModule - ) - } - process.onExit().thenApply(_ => if os.exists(jsDest) then os.remove(jsDest)) - Right((process, None)) - } - } - value(res) - case Platform.Native => - val setupPython = build.options.notForBloopOptions.doSetupPython.getOrElse(false) - val (pythonExecutable, pythonLibraryPaths, pythonExtraEnv) = - if setupPython then { - val (exec, libPaths) = value { - val python = value(createPythonInstance().orPythonDetectionError) - val pythonPropertiesOrError = for { - paths <- python.nativeLibraryPaths - executable <- python.executable - } yield (Some(executable), paths) - logger.debug( - s"Python executable and native library paths: $pythonPropertiesOrError" - ) - pythonPropertiesOrError.orPythonDetectionError + + val linkerConfig = build.options.scalaJsOptions.linkerConfig(logger) + .copy(emitWasm = true, moduleKind = ScalaJsLinkerConfig.ModuleKind.ESModule) + + val res = Package.linkJs( + builds = builds, + dest = jsDest, + mainClassOpt = Some(mainClass), + addTestInitializer = false, + config = linkerConfig, + fullOpt = value(build.options.scalaJsOptions.fullOpt), + noOpt = build.options.scalaJsOptions.noOpt.getOrElse(false), + logger = logger, + scratchDirOpt = scratchDirOpt + ).map { outputPath => + if showCommand then + runtime match { + case WasmRuntime.Deno => + Left(Runner.denoCommand(outputPath.toIO, args, denoPathOpt = denoPathOpt)) + case _ => + Left(Runner.jsCommand(outputPath.toIO, args, jsDom = false, emitWasm = true)) } - // Putting the workspace in PYTHONPATH, see - // https://github.com/VirtusLab/scala-cli/pull/1616#issuecomment-1333283174 - // for context. - (exec, libPaths, pythonPathEnv(builds.head.inputs.workspace)) - } - else - (None, Nil, Map()) - // seems conda doesn't add the lib directory to LD_LIBRARY_PATH (see conda/conda#308), - // which prevents apps from finding libpython for example, so we update it manually here - val libraryPathsEnv = - if pythonLibraryPaths.isEmpty then Map.empty else { - val prependTo = - if Properties.isWin then EnvVar.Misc.path.name - else if Properties.isMac then EnvVar.Misc.dyldLibraryPath.name - else EnvVar.Misc.ldLibraryPath.name - val currentOpt = Option(System.getenv(prependTo)) - val currentEntries = currentOpt - .map(_.split(File.pathSeparator).toSet) - .getOrElse(Set.empty) - val additionalEntries = pythonLibraryPaths.filter(!currentEntries.contains(_)) - if additionalEntries.isEmpty then Map.empty - else { - val newValue = (additionalEntries.iterator ++ currentOpt.iterator).mkString( - File.pathSeparator - ) - Map(prependTo -> newValue) + val process = value { + runtime match { + case WasmRuntime.Deno => + Runner.runDeno( + outputPath.toIO, + args, + logger, + allowExecve = effectiveAllowExecve, + emitWasm = true, + denoPathOpt = denoPathOpt + ) + case _ => + Runner.runJs( + outputPath.toIO, + args, + logger, + allowExecve = effectiveAllowExecve, + jsDom = false, + sourceMap = build.options.scalaJsOptions.emitSourceMaps, + esModule = esModule, + emitWasm = true + ) + } } + process.onExit().thenApply(_ => if os.exists(jsDest) then os.remove(jsDest)) + Right((process, None)) } - val programNameEnv = - pythonExecutable.fold(Map.empty)(py => Map("SCALAPY_PYTHON_PROGRAMNAME" -> py)) - val extraEnv = libraryPathsEnv ++ programNameEnv ++ pythonExtraEnv - val maybeResult = withNativeLauncher( - builds, - mainClass, - logger - ) { launcher => - if showCommand then - Left( - extraEnv.toVector.sorted.map { case (k, v) => s"$k=$v" } ++ - Seq(launcher.toString) ++ - args - ) - else { - val proc = Runner.runNative( - launcher = launcher.toIO, - args = args, - logger = logger, - allowExecve = effectiveAllowExecve, - extraEnv = extraEnv + } + value(res) + } + else { + // Standalone WASM runtimes - not yet supported. + // Scala.js currently produces JS-dependent WASM output. + // Standalone support requires upstream Scala.js changes (scala-js/scala-js#4991). + val runtimeName = runtime.name + val extraNote = runtime match { + case WasmRuntime.Wasmer => + " Note: Wasmer does not yet support WasmGC, which is required for Scala WASM output." + case _ => "" + } + value(Left(new UnsupportedWasmRuntimeError( + s"Standalone WASM runtime '$runtimeName' is not yet supported." + + s"$extraNote" + + " Scala.js currently produces JavaScript-dependent WASM output." + + " Standalone WASM support is tracked at: https://github.com/scala-js/scala-js/issues/4991" + + " Use --wasm-runtime node (default) or --wasm-runtime deno for JS-based WASM execution." + ))) + } + } + else + build.options.platform.value match { + case Platform.JS => + val esModule = + build.options.scalaJsOptions.moduleKindStr.exists(m => m == "es" || m == "esmodule") + + val linkerConfig = build.options.scalaJsOptions.linkerConfig(logger) + val jsDest = { + val delete = scratchDirOpt.isEmpty + scratchDirOpt.foreach(os.makeDir.all(_)) + os.temp( + dir = scratchDirOpt.orNull, + prefix = "main", + suffix = if esModule then ".mjs" else ".js", + deleteOnExit = delete ) - Right((proc, None)) } - } - value(maybeResult) - case Platform.JVM => - def fwd(s: String): String = s.replace('\\', '/') - def base(s: String): String = fwd(s).replaceAll(".*/", "") - runMode match { - case RunMode.Default => - val sourceFiles = builds.head.inputs.sourceFiles().map { - case s: ScalaFile => fwd(s.path.toString) - case s: Script => fwd(s.path.toString) - case s: MarkdownFile => fwd(s.path.toString) - case _: SbtFile => "" - case s: OnDisk => fwd(s.path.toString) - case null => "" - }.filter(_.nonEmpty).distinct - val sources = sourceFiles.mkString(File.pathSeparator) - val sourceNames = sourceFiles.map(base).mkString(File.pathSeparator) - - val baseJavaProps = build.options.javaOptions.javaOpts.toSeq.map(_.value.value) - ++ Seq(s"-Dscala.sources=$sources", s"-Dscala.source.names=$sourceNames") - val setupPython = build.options.notForBloopOptions.doSetupPython.getOrElse(false) - val (pythonJavaProps, pythonExtraEnv) = - if setupPython then { - val scalapyProps = value { - val python = value(createPythonInstance().orPythonDetectionError) - val propsOrError = python.scalapyProperties - logger.debug(s"Python Java properties: $propsOrError") - propsOrError.orPythonDetectionError - } - val props = scalapyProps.toVector.sorted.map { - case (k, v) => s"-D$k=$v" + val res = + Package.linkJs( + builds = builds, + dest = jsDest, + mainClassOpt = Some(mainClass), + addTestInitializer = false, + config = linkerConfig, + fullOpt = value(build.options.scalaJsOptions.fullOpt), + noOpt = build.options.scalaJsOptions.noOpt.getOrElse(false), + logger = logger, + scratchDirOpt = scratchDirOpt + ).map { outputPath => + val jsDom = build.options.scalaJsOptions.dom.getOrElse(false) + if showCommand then Left(Runner.jsCommand(outputPath.toIO, args, jsDom = jsDom)) + else { + val process = value { + Runner.runJs( + outputPath.toIO, + args, + logger, + allowExecve = effectiveAllowExecve, + jsDom = jsDom, + sourceMap = build.options.scalaJsOptions.emitSourceMaps, + esModule = esModule + ) } - // Putting the workspace in PYTHONPATH, see - // https://github.com/VirtusLab/scala-cli/pull/1616#issuecomment-1333283174 - // for context. - (props, pythonPathEnv(build.inputs.workspace)) + process.onExit().thenApply(_ => if os.exists(jsDest) then os.remove(jsDest)) + Right((process, None)) } - else - (Nil, Map.empty[String, String]) - val allJavaOpts = pythonJavaProps ++ baseJavaProps - if showCommand then - Left { - Runner.jvmCommand( - build.options.javaHome().value.javaCommand, - allJavaOpts, - builds.flatMap(_.fullClassPathMaybeAsJar(asJar)).distinct, - mainClass, - args, - extraEnv = pythonExtraEnv, - useManifest = build.options.notForBloopOptions.runWithManifest, - scratchDirOpt = scratchDirOpt + } + value(res) + case Platform.Native => + val setupPython = build.options.notForBloopOptions.doSetupPython.getOrElse(false) + val (pythonExecutable, pythonLibraryPaths, pythonExtraEnv) = + if setupPython then { + val (exec, libPaths) = value { + val python = value(createPythonInstance().orPythonDetectionError) + val pythonPropertiesOrError = for { + paths <- python.nativeLibraryPaths + executable <- python.executable + } yield (Some(executable), paths) + logger.debug( + s"Python executable and native library paths: $pythonPropertiesOrError" ) + pythonPropertiesOrError.orPythonDetectionError } - else { - val proc = Runner.runJvm( - javaCommand = build.options.javaHome().value.javaCommand, - javaArgs = allJavaOpts, - classPath = builds.flatMap(_.fullClassPathMaybeAsJar(asJar)).distinct, - mainClass = mainClass, - args = args, - logger = logger, - allowExecve = effectiveAllowExecve, - extraEnv = pythonExtraEnv, - useManifest = build.options.notForBloopOptions.runWithManifest, - scratchDirOpt = scratchDirOpt - ) - Right((proc, None)) + // Putting the workspace in PYTHONPATH, see + // https://github.com/VirtusLab/scala-cli/pull/1616#issuecomment-1333283174 + // for context. + (exec, libPaths, pythonPathEnv(builds.head.inputs.workspace)) } - case mode: RunMode.SparkSubmit => - value { - RunSpark.run( - builds = builds, - mainClass = mainClass, - args = args, - submitArgs = mode.submitArgs, - logger = logger, - allowExecve = effectiveAllowExecve, - showCommand = showCommand, - scratchDirOpt = scratchDirOpt - ) + else + (None, Nil, Map()) + // seems conda doesn't add the lib directory to LD_LIBRARY_PATH (see conda/conda#308), + // which prevents apps from finding libpython for example, so we update it manually here + val libraryPathsEnv = + if pythonLibraryPaths.isEmpty then Map.empty + else { + val prependTo = + if Properties.isWin then EnvVar.Misc.path.name + else if Properties.isMac then EnvVar.Misc.dyldLibraryPath.name + else EnvVar.Misc.ldLibraryPath.name + val currentOpt = Option(System.getenv(prependTo)) + val currentEntries = currentOpt + .map(_.split(File.pathSeparator).toSet) + .getOrElse(Set.empty) + val additionalEntries = pythonLibraryPaths.filter(!currentEntries.contains(_)) + if additionalEntries.isEmpty then Map.empty + else { + val newValue = (additionalEntries.iterator ++ currentOpt.iterator).mkString( + File.pathSeparator + ) + Map(prependTo -> newValue) + } } - case mode: RunMode.StandaloneSparkSubmit => - value { - RunSpark.runStandalone( - builds = builds, - mainClass = mainClass, - args = args, - submitArgs = mode.submitArgs, - logger = logger, - allowExecve = effectiveAllowExecve, - showCommand = showCommand, - scratchDirOpt = scratchDirOpt + val programNameEnv = + pythonExecutable.fold(Map.empty)(py => Map("SCALAPY_PYTHON_PROGRAMNAME" -> py)) + val extraEnv = libraryPathsEnv ++ programNameEnv ++ pythonExtraEnv + val maybeResult = withNativeLauncher( + builds, + mainClass, + logger + ) { launcher => + if showCommand then + Left( + extraEnv.toVector.sorted.map { case (k, v) => s"$k=$v" } ++ + Seq(launcher.toString) ++ + args ) - } - case RunMode.HadoopJar => - value { - RunHadoop.run( - builds = builds, - mainClass = mainClass, + else { + val proc = Runner.runNative( + launcher = launcher.toIO, args = args, logger = logger, allowExecve = effectiveAllowExecve, - showCommand = showCommand, - scratchDirOpt = scratchDirOpt + extraEnv = extraEnv ) + Right((proc, None)) } - } - } + } + value(maybeResult) + case Platform.JVM => + def fwd(s: String): String = s.replace('\\', '/') + def base(s: String): String = fwd(s).replaceAll(".*/", "") + runMode match { + case RunMode.Default => + val sourceFiles = builds.head.inputs.sourceFiles().map { + case s: ScalaFile => fwd(s.path.toString) + case s: Script => fwd(s.path.toString) + case s: MarkdownFile => fwd(s.path.toString) + case s: OnDisk => fwd(s.path.toString) + case s => s.getClass.getName + }.filter(_.nonEmpty).distinct + val sources = sourceFiles.mkString(File.pathSeparator) + val sourceNames = sourceFiles.map(base).mkString(File.pathSeparator) + + val baseJavaProps = build.options.javaOptions.javaOpts.toSeq.map(_.value.value) + ++ Seq(s"-Dscala.sources=$sources", s"-Dscala.source.names=$sourceNames") + val setupPython = + build.options.notForBloopOptions.doSetupPython.getOrElse(false) + val (pythonJavaProps, pythonExtraEnv) = + if setupPython then { + val scalapyProps = value { + val python = value(createPythonInstance().orPythonDetectionError) + val propsOrError = python.scalapyProperties + logger.debug(s"Python Java properties: $propsOrError") + propsOrError.orPythonDetectionError + } + val props = scalapyProps.toVector.sorted.map { + case (k, v) => s"-D$k=$v" + } + // Putting the workspace in PYTHONPATH, see + // https://github.com/VirtusLab/scala-cli/pull/1616#issuecomment-1333283174 + // for context. + (props, pythonPathEnv(build.inputs.workspace)) + } + else + (Nil, Map.empty[String, String]) + val allJavaOpts = pythonJavaProps ++ baseJavaProps + if showCommand then + Left { + Runner.jvmCommand( + build.options.javaHome().value.javaCommand, + allJavaOpts, + builds.flatMap(_.fullClassPathMaybeAsJar(asJar)).distinct, + mainClass, + args, + extraEnv = pythonExtraEnv, + useManifest = build.options.notForBloopOptions.runWithManifest, + scratchDirOpt = scratchDirOpt + ) + } + else { + val proc = Runner.runJvm( + javaCommand = build.options.javaHome().value.javaCommand, + javaArgs = allJavaOpts, + classPath = builds.flatMap(_.fullClassPathMaybeAsJar(asJar)).distinct, + mainClass = mainClass, + args = args, + logger = logger, + allowExecve = effectiveAllowExecve, + extraEnv = pythonExtraEnv, + useManifest = build.options.notForBloopOptions.runWithManifest, + scratchDirOpt = scratchDirOpt + ) + Right((proc, None)) + } + case mode: RunMode.SparkSubmit => + value { + RunSpark.run( + builds = builds, + mainClass = mainClass, + args = args, + submitArgs = mode.submitArgs, + logger = logger, + allowExecve = effectiveAllowExecve, + showCommand = showCommand, + scratchDirOpt = scratchDirOpt + ) + } + case mode: RunMode.StandaloneSparkSubmit => + value { + RunSpark.runStandalone( + builds = builds, + mainClass = mainClass, + args = args, + submitArgs = mode.submitArgs, + logger = logger, + allowExecve = effectiveAllowExecve, + showCommand = showCommand, + scratchDirOpt = scratchDirOpt + ) + } + case RunMode.HadoopJar => + value { + RunHadoop.run( + builds = builds, + mainClass = mainClass, + args = args, + logger = logger, + allowExecve = effectiveAllowExecve, + showCommand = showCommand, + scratchDirOpt = scratchDirOpt + ) + } + } + } } } .sequence diff --git a/modules/cli/src/main/scala/scala/cli/commands/shared/HelpGroups.scala b/modules/cli/src/main/scala/scala/cli/commands/shared/HelpGroups.scala index 8f6099a324..c943c3c904 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/shared/HelpGroups.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/shared/HelpGroups.scala @@ -17,7 +17,7 @@ enum HelpGroup: Scala, ScalaJs, ScalaNative, Secret, Signing, SuppressWarnings, SourceGenerator, Test, Uninstall, Update, - Watch, Windows, + Wasm, Watch, Windows, Version override def toString: String = this match @@ -30,6 +30,7 @@ enum HelpGroup: case SuppressWarnings => "Suppress warnings" case SourceGenerator => "Source generator" case ProjectVersion => "Project version" + case Wasm => "WebAssembly" case e => e.productPrefix enum HelpCommandGroup: diff --git a/modules/cli/src/main/scala/scala/cli/commands/shared/SharedOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/shared/SharedOptions.scala index 4ea59433fd..23ef44655f 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/shared/SharedOptions.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/shared/SharedOptions.scala @@ -57,6 +57,8 @@ final case class SharedOptions( js: ScalaJsOptions = ScalaJsOptions(), @Recurse native: ScalaNativeOptions = ScalaNativeOptions(), + @Recurse + wasmOptions: WasmOptions = WasmOptions(), @Recurse compilationServer: SharedCompilationServerOptions = SharedCompilationServerOptions(), @Recurse @@ -296,6 +298,16 @@ final case class SharedOptions( ) } + private def buildWasmOptions(opts: WasmOptions): options.WasmOptions = { + import opts._ + options.WasmOptions( + enabled = wasm, + runtime = + wasmRuntime.flatMap(options.WasmRuntime.parse).getOrElse(options.WasmRuntime.default), + denoVersion = denoVersion + ) + } + lazy val scalacOptionsFromFiles: List[String] = scalac.argsFiles.flatMap(argFile => ArgSplitter.splitToArgs(os.read(os.Path(argFile.file, os.pwd))) @@ -320,21 +332,27 @@ final case class SharedOptions( case _ => } val parsedPlatform = platform.map(Platform.normalize).flatMap(Platform.parse) - val platformOpt = value { - (parsedPlatform, js.js, native.native) match { - case (Some(p: Platform.JS.type), _, false) => Right(Some(p)) - case (Some(p: Platform.Native.type), false, _) => Right(Some(p)) - case (Some(p: Platform.JVM.type), false, false) => Right(Some(p)) - case (Some(p), _, _) => - val jsSeq = if (js.js) Seq(Platform.JS) else Seq.empty + // WASM mode requires Scala.js platform for compilation + val wasmEnabled = wasmOptions.wasm + val platformOpt = value { + (parsedPlatform, js.js, native.native, wasmEnabled) match { + case (Some(p: Platform.JS.type), _, false, _) => Right(Some(p)) + case (Some(p: Platform.Native.type), false, _, false) => Right(Some(p)) + case (Some(p: Platform.JVM.type), false, false, false) => Right(Some(p)) + case (Some(p), _, _, _) => + val jsSeq = if (js.js || wasmEnabled) Seq(Platform.JS) else Seq.empty val nativeSeq = if (native.native) Seq(Platform.Native) else Seq.empty val platformsSeq = Seq(p) ++ jsSeq ++ nativeSeq Left(new AmbiguousPlatformError(platformsSeq.distinct.map(_.toString))) - case (_, true, true) => + case (_, true, true, _) => Left(new AmbiguousPlatformError(Seq(Platform.JS.toString, Platform.Native.toString))) - case (_, true, _) => Right(Some(Platform.JS)) - case (_, _, true) => Right(Some(Platform.Native)) - case _ => Right(None) + case (_, _, true, true) => + Left(new AmbiguousPlatformError(Seq(Platform.Native.toString, "WASM (requires JS)"))) + case (_, true, _, _) => Right(Some(Platform.JS)) + case (_, _, _, true) => + Right(Some(Platform.JS)) // WASM requires JS compilation (Scala.js WASM backend) + case (_, _, true, _) => Right(Some(Platform.Native)) + case _ => Right(None) } } val (assumedSourceJars, extraRegularJarsAndClasspath) = @@ -421,6 +439,7 @@ final case class SharedOptions( ), scalaJsOptions = scalaJsOptions(js), scalaNativeOptions = snOpts, + wasmOptions = buildWasmOptions(wasmOptions), javaOptions = value(scala.cli.commands.util.JvmUtils.javaOptions(jvm)), jmhOptions = scala.build.options.JmhOptions( jmhVersion = benchmarking.jmhVersion, diff --git a/modules/cli/src/main/scala/scala/cli/commands/shared/WasmOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/shared/WasmOptions.scala new file mode 100644 index 0000000000..d8793afaa2 --- /dev/null +++ b/modules/cli/src/main/scala/scala/cli/commands/shared/WasmOptions.scala @@ -0,0 +1,32 @@ +package scala.cli.commands.shared + +import caseapp.* +import com.github.plokhotnyuk.jsoniter_scala.core.* +import com.github.plokhotnyuk.jsoniter_scala.macros.* + +import scala.cli.commands.tags + +// format: off +final case class WasmOptions( + @Group(HelpGroup.Scala.toString) + @Tag(tags.experimental) + @HelpMessage("Enable WebAssembly output (Scala.js WASM backend). Uses Node.js by default. To show more options for WASM pass `--help-wasm`") + wasm: Boolean = false, + + @Group(HelpGroup.Wasm.toString) + @Tag(tags.experimental) + @HelpMessage("WASM runtime to use: node (default), deno. Standalone runtimes (wasmtime, wasmedge) planned for future releases.") + wasmRuntime: Option[String] = None, + + @Group(HelpGroup.Wasm.toString) + @Tag(tags.experimental) + @HelpMessage("Version of Deno to use. If Deno is not found on PATH, it will be downloaded automatically.") + denoVersion: Option[String] = None +) +// format: on + +object WasmOptions { + implicit lazy val parser: Parser[WasmOptions] = Parser.derive + implicit lazy val help: Help[WasmOptions] = Help.derive + implicit lazy val jsonCodec: JsonValueCodec[WasmOptions] = JsonCodecMaker.make +} diff --git a/modules/cli/src/main/scala/scala/cli/internal/WasmRuntimeDownloader.scala b/modules/cli/src/main/scala/scala/cli/internal/WasmRuntimeDownloader.scala new file mode 100644 index 0000000000..751e087034 --- /dev/null +++ b/modules/cli/src/main/scala/scala/cli/internal/WasmRuntimeDownloader.scala @@ -0,0 +1,104 @@ +package scala.cli.internal + +import coursier.cache.ArchiveCache +import coursier.util.Task + +import java.util.Locale + +import scala.build.EitherCps.{either, value} +import scala.build.Logger +import scala.build.errors.BuildException +import scala.build.internal.FetchExternalBinary +import scala.util.Properties + +/** Resolves Deno binary for WASM execution. + * + * Deno is first looked up on the system PATH. If not found, it is downloaded from GitHub releases + * and cached via Coursier's ArchiveCache. + */ +object WasmRuntimeDownloader { + + /** Returns the command to run Deno. + * + * First checks system PATH, otherwise downloads the binary. + */ + def denoCommand( + version: String, + archiveCache: ArchiveCache[Task], + logger: Logger + ): Either[BuildException, Seq[String]] = either { + findOnPath("deno") match { + case Some(path) => + logger.debug(s"Using system deno at: $path") + Seq(path) + case None => + logger.message(s"Deno not found on PATH, downloading v$version...") + val binary = value(fetchDeno(version, archiveCache, logger)) + Seq(binary.toString) + } + } + + /** Find an executable on the system PATH */ + private def findOnPath(name: String): Option[String] = { + val exeName = if (Properties.isWin) s"$name.exe" else name + sys.env.get("PATH").flatMap { pathEnv => + pathEnv.split(java.io.File.pathSeparator).view.map { dir => + val file = new java.io.File(dir, exeName) + if (file.exists() && file.canExecute) Some(file.getAbsolutePath) + else None + }.find(_.isDefined).flatten + } + } + + private def detectOs(win: String, linux: String, mac: String): Either[BuildException, String] = + if (Properties.isWin) Right(win) + else if (Properties.isLinux) Right(linux) + else if (Properties.isMac) Right(mac) + else Left(new WasmRuntimeDownloadError(s"Unsupported OS: ${sys.props("os.name")}")) + + private def detectArch64(x86_64: String, aarch64: String): Either[BuildException, String] = + sys.props("os.arch").toLowerCase(Locale.ROOT) match { + case "amd64" | "x86_64" => Right(x86_64) + case "aarch64" | "arm64" => Right(aarch64) + case other => Left(new WasmRuntimeDownloadError(s"Unsupported architecture: $other")) + } + + /** Fetches Deno binary for the current platform. + * + * Deno releases are at: + * https://github.com/denoland/deno/releases/download/v{version}/deno-{platform}.zip + */ + private def fetchDeno( + version: String, + archiveCache: ArchiveCache[Task], + logger: Logger + ): Either[BuildException, os.Path] = either { + val platform = value(denoPlatform) + val url = s"https://github.com/denoland/deno/releases/download/v$version/deno-$platform.zip" + + val binaryOpt = value { + FetchExternalBinary.fetchLauncher( + url = url, + changing = false, + archiveCache = archiveCache, + logger = logger, + launcherPrefix = "deno", + launcherPathOpt = None, + makeExecutable = true + ) + } + + binaryOpt.getOrElse { + value(Left(new WasmRuntimeDownloadError(s"Could not download Deno v$version for $platform"))) + } + } + + /** Platform suffix for Deno downloads */ + private def denoPlatform: Either[BuildException, String] = either { + val arch = value(detectArch64("x86_64", "aarch64")) + val os = value(detectOs("pc-windows-msvc", "unknown-linux-gnu", "apple-darwin")) + s"$arch-$os" + } +} + +class WasmRuntimeDownloadError(message: String) extends BuildException(message) diff --git a/modules/core/src/main/scala/scala/build/errors/DenoNotFoundError.scala b/modules/core/src/main/scala/scala/build/errors/DenoNotFoundError.scala new file mode 100644 index 0000000000..4566e346a4 --- /dev/null +++ b/modules/core/src/main/scala/scala/build/errors/DenoNotFoundError.scala @@ -0,0 +1,5 @@ +package scala.build.errors + +final class DenoNotFoundError extends BuildException( + "Deno was not found on the PATH. Install Deno from https://deno.land/ or use --wasm-runtime node" + ) diff --git a/modules/core/src/main/scala/scala/build/errors/UnsupportedWasmRuntimeError.scala b/modules/core/src/main/scala/scala/build/errors/UnsupportedWasmRuntimeError.scala new file mode 100644 index 0000000000..b663b6a956 --- /dev/null +++ b/modules/core/src/main/scala/scala/build/errors/UnsupportedWasmRuntimeError.scala @@ -0,0 +1,3 @@ +package scala.build.errors + +final class UnsupportedWasmRuntimeError(message: String) extends BuildException(message) diff --git a/modules/directives/src/main/scala/scala/build/preprocessing/directives/Wasm.scala b/modules/directives/src/main/scala/scala/build/preprocessing/directives/Wasm.scala new file mode 100644 index 0000000000..361fcc32ab --- /dev/null +++ b/modules/directives/src/main/scala/scala/build/preprocessing/directives/Wasm.scala @@ -0,0 +1,52 @@ +package scala.build.preprocessing.directives + +import scala.build.Positioned +import scala.build.directives.* +import scala.build.errors.BuildException +import scala.build.options.{BuildOptions, Platform, ScalaOptions, WasmOptions, WasmRuntime} +import scala.cli.commands.SpecificationLevel + +@DirectiveGroupName("WASM options") +@DirectiveExamples("//> using wasm") +@DirectiveExamples("//> using wasmRuntime node") +@DirectiveExamples("//> using wasmRuntime deno") +@DirectiveExamples("//> using denoVersion 2.1.4") +@DirectiveUsage( + "//> using wasm|wasmRuntime|denoVersion _value_", + """ + |`//> using wasm` _true|false_ + | + |`//> using wasm` + | + |`//> using wasmRuntime` _node|deno|wasmtime|wasmedge|wasmer_ + | + |`//> using denoVersion` _value_ + |""".stripMargin +) +@DirectiveDescription("Add WebAssembly options") +@DirectiveLevel(SpecificationLevel.EXPERIMENTAL) +final case class Wasm( + wasm: Option[Boolean] = None, + wasmRuntime: Option[String] = None, + denoVersion: Option[String] = None +) extends HasBuildOptions { + def buildOptions: Either[BuildException, BuildOptions] = { + val parsedRuntime = wasmRuntime.flatMap(WasmRuntime.parse) + val wasmOptions = WasmOptions( + enabled = wasm.getOrElse(false), + runtime = parsedRuntime.getOrElse(WasmRuntime.default), + denoVersion = denoVersion + ) + // When WASM is enabled, force Platform.JS (Scala.js WASM backend requires JS compilation) + val scalaOptions = + if (wasm.getOrElse(false)) + ScalaOptions(platform = Some(Positioned.none(Platform.JS))) + else + ScalaOptions() + Right(BuildOptions(scalaOptions = scalaOptions, wasmOptions = wasmOptions)) + } +} + +object Wasm { + val handler: DirectiveHandler[Wasm] = DirectiveHandler.derive +} diff --git a/modules/integration/src/test/scala/scala/cli/integration/RunScalaJsTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/RunScalaJsTestDefinitions.scala index 4745787924..8436bc2e0f 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/RunScalaJsTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/RunScalaJsTestDefinitions.scala @@ -325,11 +325,286 @@ trait RunScalaJsTestDefinitions { this: RunTestDefinitions => .call(cwd = root).out.trim() val path = absOutDir / "main.wasm" expect(os.exists(path)) + } + } + + test("Run with --wasm flag") { + val inputs = TestInputs( + os.rel / "Hello.scala" -> + """object Hello { + | def main(args: Array[String]): Unit = println("Hello from WASM!") + |} + |""".stripMargin + ) + inputs.fromRoot { root => + val output = os.proc( + TestUtil.cli, + "--power", + "run", + "Hello.scala", + "--wasm", + "--wasm-runtime", + "node", + extraOptions + ).call(cwd = root).out.trim() + expect(output == "Hello from WASM!") + } + } - // TODO : Run WASM using node. Requires node 22. + test("Run with --wasm uses Node.js by default") { + val inputs = TestInputs( + os.rel / "Hello.scala" -> + """object Hello { + | def main(args: Array[String]): Unit = println("Hello default WASM!") + |} + |""".stripMargin + ) + inputs.fromRoot { root => + val output = os.proc( + TestUtil.cli, + "--power", + "run", + "Hello.scala", + "--wasm", + extraOptions + ).call(cwd = root).out.trim() + expect(output == "Hello default WASM!") } } + test("Run with //> using wasm directive") { + val inputs = TestInputs( + os.rel / "Hello.scala" -> + """//> using wasm + |//> using wasmRuntime node + |object Hello { + | def main(args: Array[String]): Unit = println("Hello from WASM directive!") + |} + |""".stripMargin + ) + inputs.fromRoot { root => + val output = os.proc( + TestUtil.cli, + "--power", + "run", + "Hello.scala", + extraOptions + ).call(cwd = root).out.trim() + expect(output == "Hello from WASM directive!") + } + } + + test("WASM passes arguments to program") { + // Scala.js always passes an empty Array[String] to main(args), + // so we must read process.argv directly via JS interop. + val inputs = TestInputs( + os.rel / "Hello.scala" -> + """import scala.scalajs.js + |import scala.scalajs.js.Dynamic.global + |object Hello { + | def main(args: Array[String]): Unit = { + | val argv = global.process.argv.asInstanceOf[js.Array[String]].drop(2).toSeq + | println(argv.mkString(" ")) + | } + |} + |""".stripMargin + ) + inputs.fromRoot { root => + val output = os.proc( + TestUtil.cli, + "--power", + "run", + "Hello.scala", + "--wasm", + "--wasm-runtime", + "node", + extraOptions, + "--", + "foo", + "bar", + "baz" + ).call(cwd = root).out.trim() + expect(output == "foo bar baz") + } + } + + for (runtime <- Seq("wasmtime", "wasmedge", "wasmer")) + test(s"Unsupported WASM runtime '$runtime' gives clear error") { + val inputs = TestInputs( + os.rel / "Hello.scala" -> + """object Hello { + | def main(args: Array[String]): Unit = println("Hello!") + |} + |""".stripMargin + ) + inputs.fromRoot { root => + val res = os.proc( + TestUtil.cli, + "--power", + "run", + "Hello.scala", + "--wasm", + "--wasm-runtime", + runtime, + extraOptions + ).call(cwd = root, check = false, mergeErrIntoOut = true) + expect(res.exitCode != 0) + expect(res.out.trim().contains("not yet supported")) + expect(res.out.trim().contains("scala-js/scala-js/issues/4991")) + } + } + + if (TestUtil.fromPath("deno").isDefined) + test("Run with --wasm-runtime deno") { + val inputs = TestInputs( + os.rel / "Hello.scala" -> + """object Hello { + | def main(args: Array[String]): Unit = println("Hello from Deno WASM!") + |} + |""".stripMargin + ) + inputs.fromRoot { root => + val output = os.proc( + TestUtil.cli, + "--power", + "run", + "Hello.scala", + "--wasm", + "--wasm-runtime", + "deno", + extraOptions + ).call(cwd = root).out.trim() + expect(output == "Hello from Deno WASM!") + } + } + + test("WASM multiple source files") { + val inputs = TestInputs( + os.rel / "Greeter.scala" -> + """trait Greeter { + | def greet(name: String): String + |} + | + |object EnthusiasticGreeter extends Greeter { + | def greet(name: String): String = s"Hello, $name!" + |} + |""".stripMargin, + os.rel / "Main.scala" -> + """object Main { + | def main(args: Array[String]): Unit = { + | println(EnthusiasticGreeter.greet("WASM")) + | } + |} + |""".stripMargin + ) + inputs.fromRoot { root => + val output = os.proc( + TestUtil.cli, + "--power", + "run", + "Main.scala", + "Greeter.scala", + "--wasm", + "--wasm-runtime", + "node", + extraOptions + ).call(cwd = root).out.trim() + expect(output == "Hello, WASM!") + } + } + + test("WASM exception handling") { + val inputs = TestInputs( + os.rel / "Hello.scala" -> + """object Hello { + | def riskyOp(x: Int): Int = + | if (x == 0) throw new IllegalArgumentException("zero!") + | else 100 / x + | + | def main(args: Array[String]): Unit = { + | val ok = try riskyOp(5).toString catch { case e: Exception => s"err: ${e.getMessage}" } + | val caught = try riskyOp(0).toString catch { case e: Exception => s"caught: ${e.getMessage}" } + | println(ok) + | println(caught) + | } + |} + |""".stripMargin + ) + inputs.fromRoot { root => + val output = os.proc( + TestUtil.cli, + "--power", + "run", + "Hello.scala", + "--wasm", + "--wasm-runtime", + "node", + extraOptions + ).call(cwd = root).out.trim() + val lines = output.linesIterator.toSeq + expect(lines.contains("20")) + expect(lines.contains("caught: zero!")) + } + } + + test("WASM collections and higher-order functions") { + val inputs = TestInputs( + os.rel / "Hello.scala" -> + """object Hello { + | def fib(n: Int): Int = if (n <= 1) n else fib(n - 1) + fib(n - 2) + | + | def main(args: Array[String]): Unit = { + | val fibs = (0 to 7).map(fib).toList + | println(fibs.mkString(", ")) + | println(fibs.filter(_ % 2 == 0).sum) + | println(fibs.foldLeft(0)(_ + _)) + | } + |} + |""".stripMargin + ) + inputs.fromRoot { root => + val output = os.proc( + TestUtil.cli, + "--power", + "run", + "Hello.scala", + "--wasm", + "--wasm-runtime", + "node", + extraOptions + ).call(cwd = root).out.trim() + val lines = output.linesIterator.toSeq + expect(lines.contains("0, 1, 1, 2, 3, 5, 8, 13")) + expect(lines.contains("10")) // 0 + 2 + 8 = 10 + expect(lines.contains("33")) // sum of first 8 fibs + } + } + + if (!actualScalaVersion.startsWith("2")) + test("WASM @main annotation (Scala 3)") { + // Scala.js always passes empty args to main, so @main with parameters won't work. + // Test @main without parameters instead. + val inputs = TestInputs( + os.rel / "Hello.scala" -> + """@main def hello(): Unit = + | println("Hello, Scala3!") + |""".stripMargin + ) + inputs.fromRoot { root => + val output = os.proc( + TestUtil.cli, + "--power", + "run", + "Hello.scala", + "--wasm", + "--wasm-runtime", + "node", + extraOptions + ).call(cwd = root).out.trim() + expect(output == "Hello, Scala3!") + } + } + test("remap imports directive") { val importmapFile = "importmap.json" val outDir = "out" diff --git a/modules/options/src/main/scala/scala/build/options/BuildOptions.scala b/modules/options/src/main/scala/scala/build/options/BuildOptions.scala index aa96da1d41..a8547fd86c 100644 --- a/modules/options/src/main/scala/scala/build/options/BuildOptions.scala +++ b/modules/options/src/main/scala/scala/build/options/BuildOptions.scala @@ -37,6 +37,7 @@ final case class BuildOptions( scalaOptions: ScalaOptions = ScalaOptions(), scalaJsOptions: ScalaJsOptions = ScalaJsOptions(), scalaNativeOptions: ScalaNativeOptions = ScalaNativeOptions(), + wasmOptions: WasmOptions = WasmOptions(), internalDependencies: InternalDependenciesOptions = InternalDependenciesOptions(), javaOptions: JavaOptions = JavaOptions(), jmhOptions: JmhOptions = JmhOptions(), diff --git a/modules/options/src/main/scala/scala/build/options/WasmOptions.scala b/modules/options/src/main/scala/scala/build/options/WasmOptions.scala new file mode 100644 index 0000000000..f96d697803 --- /dev/null +++ b/modules/options/src/main/scala/scala/build/options/WasmOptions.scala @@ -0,0 +1,26 @@ +package scala.build.options + +import scala.build.internal.Constants + +/** Options for WebAssembly compilation and execution. + * + * @param enabled + * If true, enable WASM output (Scala.js WASM backend) + * @param runtime + * The WASM runtime to use for execution (node, deno, wasmtime, wasmedge, wasmer) + * @param denoVersion + * Version of Deno to download (if not found on PATH) + */ +final case class WasmOptions( + enabled: Boolean = false, + runtime: WasmRuntime = WasmRuntime.default, + denoVersion: Option[String] = None +) { + def finalDenoVersion: String = + denoVersion.filter(_.nonEmpty).getOrElse(Constants.defaultDenoVersion) +} + +object WasmOptions { + implicit val hasHashData: HasHashData[WasmOptions] = HasHashData.derive + implicit val monoid: ConfigMonoid[WasmOptions] = ConfigMonoid.derive +} diff --git a/modules/options/src/main/scala/scala/build/options/WasmRuntime.scala b/modules/options/src/main/scala/scala/build/options/WasmRuntime.scala new file mode 100644 index 0000000000..f88f3028ab --- /dev/null +++ b/modules/options/src/main/scala/scala/build/options/WasmRuntime.scala @@ -0,0 +1,54 @@ +package scala.build.options + +import java.util.Locale + +/** Represents available WebAssembly runtimes for execution. + * + * JS-based runtimes (work now with Scala.js WASM backend): + * - Node: Uses Node.js (V8 engine) with JavaScript loader + * - Deno: Uses Deno (V8 engine) with ES module support + * + * Standalone runtimes (future, requires upstream Scala.js standalone WASM support): + * - Wasmtime: Primary standalone target, full WasmGC + Component Model + * - WasmEdge: Secondary standalone target, CNCF cloud-native runtime + * - Wasmer: Placeholder, no WasmGC support yet + */ +sealed abstract class WasmRuntime(val name: String) { + def isJsBased: Boolean = this match { + case WasmRuntime.Node | WasmRuntime.Deno => true + case _ => false + } + def isStandalone: Boolean = !isJsBased +} + +object WasmRuntime { + // JS-based runtimes (work now) + case object Node extends WasmRuntime("node") + case object Deno extends WasmRuntime("deno") + // Standalone runtimes (future - requires upstream Scala.js standalone WASM support) + case object Wasmtime extends WasmRuntime("wasmtime") + case object WasmEdge extends WasmRuntime("wasmedge") + case object Wasmer extends WasmRuntime("wasmer") + + val all: Seq[WasmRuntime] = Seq(Node, Deno, Wasmtime, WasmEdge, Wasmer) + + def default: WasmRuntime = Node + + def parse(s: String): Option[WasmRuntime] = + s.trim.toLowerCase(Locale.ROOT) match { + case "node" | "nodejs" => Some(Node) + case "deno" => Some(Deno) + case "wasmtime" => Some(Wasmtime) + case "wasmedge" => Some(WasmEdge) + case "wasmer" => Some(Wasmer) + case _ => None + } + + implicit val hashedType: HashedType[WasmRuntime] = runtime => runtime.name + + implicit val hasHashData: HasHashData[WasmRuntime] = HasHashData.asIs + + implicit val monoid: ConfigMonoid[WasmRuntime] = ConfigMonoid.instance[WasmRuntime](default) { + (a, b) => if (b == default) a else b + } +} diff --git a/website/docs/reference/cli-options.md b/website/docs/reference/cli-options.md index ee063c86e8..0247cb83df 100644 --- a/website/docs/reference/cli-options.md +++ b/website/docs/reference/cli-options.md @@ -1975,6 +1975,32 @@ A github token used to access GitHub. Not needed in most cases. Don't check for the newest available Scala CLI version upstream +## WebAssembly options + +Available in commands: + +[`run`](./commands.md#run), [`shebang`](./commands.md#shebang) + + + +### `--wasm` + +[Experimental] + +Enable WebAssembly output (Scala.js WASM backend). Uses Node.js by default. To show more options for WASM pass `--help-wasm` + +### `--wasm-runtime` + +[Experimental] + +WASM runtime to use: node (default), deno. Standalone runtimes (wasmtime, wasmedge) planned for future releases. + +### `--deno-version` + +[Experimental] + +Version of Deno to use. If Deno is not found on PATH, it will be downloaded automatically. + ## Watch options Available in commands: diff --git a/website/docs/reference/directives.md b/website/docs/reference/directives.md index 1f093c234a..3e2c313f34 100644 --- a/website/docs/reference/directives.md +++ b/website/docs/reference/directives.md @@ -680,6 +680,27 @@ Add Scala.js options `//> using jsEmitWasm` +### WebAssembly + +Add WebAssembly options + +`//> using wasm` _true|false_ + +`//> using wasm` + +`//> using wasmRuntime` _node|deno|wasmtime|wasmedge|wasmer_ + +`//> using denoVersion` _value_ + +#### Examples +`//> using wasm` + +`//> using wasmRuntime node` + +`//> using wasmRuntime deno` + +`//> using denoVersion 2.1.4` + ### Test framework Set the test framework From 1e359b0b5f80e54fa4822ad6043964720f4fa276 Mon Sep 17 00:00:00 2001 From: lostflydev Date: Thu, 2 Apr 2026 08:44:21 +0500 Subject: [PATCH 63/64] Review fixes: remove runtime download and unsupported standalone runtimes - Move --wasm flag to dedicated Wasm help group with --help-wasm option - Simplify wasmOptions parsing with fold/toRight pattern - Add runtime validation with UnrecognizedWasmRuntimeError in directives - Auto-enable WASM when wasmRuntime directive is set - Update reference documentation Code style: simplify denoNeedsWasmFlag, explicit runtime match cases, clean type annotation, scalfmt --- build.mill | 2 - .../scala/scala/build/internal/Runner.scala | 16 +- .../scala/scala/cli/commands/run/Run.scala | 145 +++++++----------- .../commands/shared/HelpGroupOptions.scala | 9 +- .../cli/commands/shared/SharedOptions.scala | 26 +++- .../cli/commands/shared/WasmOptions.scala | 11 +- .../cli/internal/WasmRuntimeDownloader.scala | 104 ------------- .../errors/UnrecognizedWasmRuntimeError.scala | 4 + .../errors/UnsupportedWasmRuntimeError.scala | 3 - .../build/preprocessing/directives/Wasm.scala | 46 +++--- .../RunScalaJsTestDefinitions.scala | 26 ---- .../scala/build/options/WasmOptions.scala | 14 +- .../scala/build/options/WasmRuntime.scala | 23 +-- website/docs/reference/cli-options.md | 22 ++- website/docs/reference/commands.md | 30 ++-- website/docs/reference/directives.md | 40 +++-- .../reference/scala-command/cli-options.md | 18 +++ .../docs/reference/scala-command/commands.md | 18 +-- .../scala-command/runner-specification.md | 54 +++++++ 19 files changed, 247 insertions(+), 364 deletions(-) delete mode 100644 modules/cli/src/main/scala/scala/cli/internal/WasmRuntimeDownloader.scala create mode 100644 modules/core/src/main/scala/scala/build/errors/UnrecognizedWasmRuntimeError.scala delete mode 100644 modules/core/src/main/scala/scala/build/errors/UnsupportedWasmRuntimeError.scala diff --git a/build.mill b/build.mill index 87d819031d..e43785f8f7 100644 --- a/build.mill +++ b/build.mill @@ -533,8 +533,6 @@ trait Core extends ScalaCliCrossSbtModule | def toolkitVersionForNative04 = "${Deps.toolkitVersionForNative04}" | def toolkitVersionForNative05 = "${Deps.toolkitVersionForNative05}" | - | def defaultDenoVersion = "2.1.4" - | | def typelevelOrganization = "${Deps.typelevelToolkit.dep.module.organization.value}" | def typelevelToolkitDefaultVersion = "${Deps.typelevelToolkitVersion}" | def typelevelToolkitMaxScalaNative = "${Deps.Versions.maxScalaNativeForTypelevelToolkit}" diff --git a/modules/build/src/main/scala/scala/build/internal/Runner.scala b/modules/build/src/main/scala/scala/build/internal/Runner.scala index fb4bec255b..3858b47d28 100644 --- a/modules/build/src/main/scala/scala/build/internal/Runner.scala +++ b/modules/build/src/main/scala/scala/build/internal/Runner.scala @@ -238,10 +238,7 @@ object Runner { // Deno 2.x+ bundles V8 13+ which has wasm-exnref enabled by default; no flag needed. private def denoNeedsWasmFlag: Boolean = - denoMajorVersion.flatMap { major => - if (major >= 2) Some(false) // Deno 2.x+ has V8 13+ with wasm-exnref by default - else Some(true) - }.getOrElse(true) // true if unknown + denoMajorVersion.forall(_ < 2) // true if unknown or < 2 private def endsWithCaseInsensitive(s: String, suffix: String): Boolean = s.length >= suffix.length && @@ -380,10 +377,9 @@ object Runner { def denoCommand( entrypoint: File, - args: Seq[String], - denoPathOpt: Option[String] = None + args: Seq[String] ): Seq[String] = { - val denoPath = denoPathOpt.getOrElse(findInPath("deno").fold("deno")(_.toString)) + val denoPath = findInPath("deno").fold("deno")(_.toString) val denoFlags = Seq("run", "--allow-read") Seq(denoPath) ++ denoFlags ++ Seq(entrypoint.getAbsolutePath) ++ args } @@ -393,14 +389,12 @@ object Runner { args: Seq[String], logger: Logger, allowExecve: Boolean = false, - emitWasm: Boolean = false, - denoPathOpt: Option[String] = None + emitWasm: Boolean = false ): Either[BuildException, Process] = either { - val denoPath: String = denoPathOpt.getOrElse { + val denoPath: String = value(findInPath("deno") .map(_.toString) .toRight(DenoNotFoundError())) - } val denoFlags = Seq("run", "--allow-read") val extraEnv = if (emitWasm && denoNeedsWasmFlag) Map("DENO_V8_FLAGS" -> "--experimental-wasm-exnref") diff --git a/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala b/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala index af484d123d..c8253ee14e 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala @@ -12,7 +12,7 @@ import java.util.concurrent.atomic.AtomicReference import scala.build.* import scala.build.EitherCps.{either, value} import scala.build.Ops.* -import scala.build.errors.{BuildException, CompositeBuildException, UnsupportedWasmRuntimeError} +import scala.build.errors.{BuildException, CompositeBuildException} import scala.build.input.* import scala.build.internal.{Constants, Runner, ScalaJsLinkerConfig} import scala.build.internals.ConsoleUtils.ScalaCliConsole @@ -28,7 +28,7 @@ import scala.cli.commands.util.BuildCommandHelpers.* import scala.cli.commands.util.{BuildCommandHelpers, RunHadoop, RunSpark} import scala.cli.commands.{CommandUtils, ScalaCommand, SpecificationLevel, WatchUtil} import scala.cli.config.Keys -import scala.cli.internal.{ProcUtil, WasmRuntimeDownloader} +import scala.cli.internal.ProcUtil import scala.cli.packaging.Library.fullClassPathMaybeAsJar import scala.cli.util.ArgHelpers.* import scala.cli.util.ConfigDbUtils @@ -478,101 +478,66 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers { // Check if WASM mode is requested if wasmOpts.enabled then { - val runtime = wasmOpts.runtime - - if runtime.isJsBased then { - // JS-based WASM path - uses Scala.js WASM with JavaScript helpers (Node.js or Deno) - val esModule = true // WASM backend uses ES modules - scratchDirOpt.foreach(os.makeDir.all(_)) - val jsDest = os.temp( - dir = scratchDirOpt.orNull, - prefix = "main", - suffix = ".mjs", - deleteOnExit = scratchDirOpt.isEmpty - ) - - // Resolve Deno binary: check PATH first, download if needed - val denoPathOpt: Option[String] = runtime match { - case WasmRuntime.Deno => - val denoCmd = value(WasmRuntimeDownloader.denoCommand( - wasmOpts.finalDenoVersion, - build.options.archiveCache, - logger - )) - Some(denoCmd.head) - case _ => None - } + val runtime = wasmOpts.runtime + val esModule = true // WASM backend uses ES modules + scratchDirOpt.foreach(os.makeDir.all(_)) + val jsDest = os.temp( + dir = scratchDirOpt.orNull, + prefix = "main", + suffix = ".mjs", + deleteOnExit = scratchDirOpt.isEmpty + ) - val linkerConfig = build.options.scalaJsOptions.linkerConfig(logger) - .copy(emitWasm = true, moduleKind = ScalaJsLinkerConfig.ModuleKind.ESModule) - - val res = Package.linkJs( - builds = builds, - dest = jsDest, - mainClassOpt = Some(mainClass), - addTestInitializer = false, - config = linkerConfig, - fullOpt = value(build.options.scalaJsOptions.fullOpt), - noOpt = build.options.scalaJsOptions.noOpt.getOrElse(false), - logger = logger, - scratchDirOpt = scratchDirOpt - ).map { outputPath => - if showCommand then + val linkerConfig = build.options.scalaJsOptions.linkerConfig(logger) + .copy(emitWasm = true, moduleKind = ScalaJsLinkerConfig.ModuleKind.ESModule) + + val res = Package.linkJs( + builds = builds, + dest = jsDest, + mainClassOpt = Some(mainClass), + addTestInitializer = false, + config = linkerConfig, + fullOpt = value(build.options.scalaJsOptions.fullOpt), + noOpt = build.options.scalaJsOptions.noOpt.getOrElse(false), + logger = logger, + scratchDirOpt = scratchDirOpt + ).map { outputPath => + if showCommand then + runtime match { + case WasmRuntime.Deno => + Left(Runner.denoCommand(outputPath.toIO, args)) + case WasmRuntime.Node => + Left(Runner.jsCommand(outputPath.toIO, args, jsDom = false, emitWasm = true)) + } + else { + val process = value { runtime match { case WasmRuntime.Deno => - Left(Runner.denoCommand(outputPath.toIO, args, denoPathOpt = denoPathOpt)) - case _ => - Left(Runner.jsCommand(outputPath.toIO, args, jsDom = false, emitWasm = true)) - } - else { - val process = value { - runtime match { - case WasmRuntime.Deno => - Runner.runDeno( - outputPath.toIO, - args, - logger, - allowExecve = effectiveAllowExecve, - emitWasm = true, - denoPathOpt = denoPathOpt - ) - case _ => - Runner.runJs( - outputPath.toIO, - args, - logger, - allowExecve = effectiveAllowExecve, - jsDom = false, - sourceMap = build.options.scalaJsOptions.emitSourceMaps, - esModule = esModule, - emitWasm = true - ) - } + Runner.runDeno( + outputPath.toIO, + args, + logger, + allowExecve = effectiveAllowExecve, + emitWasm = true + ) + case WasmRuntime.Node => + Runner.runJs( + outputPath.toIO, + args, + logger, + allowExecve = effectiveAllowExecve, + jsDom = false, + sourceMap = build.options.scalaJsOptions.emitSourceMaps, + esModule = esModule, + emitWasm = true + ) } - process.onExit().thenApply(_ => if os.exists(jsDest) then os.remove(jsDest)) - Right((process, None)) } + process.onExit().thenApply(_ => if os.exists(jsDest) then os.remove(jsDest)) + Right((process, None)) } - value(res) - } - else { - // Standalone WASM runtimes - not yet supported. - // Scala.js currently produces JS-dependent WASM output. - // Standalone support requires upstream Scala.js changes (scala-js/scala-js#4991). - val runtimeName = runtime.name - val extraNote = runtime match { - case WasmRuntime.Wasmer => - " Note: Wasmer does not yet support WasmGC, which is required for Scala WASM output." - case _ => "" - } - value(Left(new UnsupportedWasmRuntimeError( - s"Standalone WASM runtime '$runtimeName' is not yet supported." + - s"$extraNote" + - " Scala.js currently produces JavaScript-dependent WASM output." + - " Standalone WASM support is tracked at: https://github.com/scala-js/scala-js/issues/4991" + - " Use --wasm-runtime node (default) or --wasm-runtime deno for JS-based WASM execution." - ))) } + value(res) } else build.options.platform.value match { diff --git a/modules/cli/src/main/scala/scala/cli/commands/shared/HelpGroupOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/shared/HelpGroupOptions.scala index ef012e22f0..76d78dcb19 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/shared/HelpGroupOptions.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/shared/HelpGroupOptions.scala @@ -49,7 +49,13 @@ case class HelpGroupOptions( @Name("fmtHelp") @Tag(tags.implementation) @Tag(tags.inShortHelp) - helpScalafmt: Boolean = false + helpScalafmt: Boolean = false, + @Group(HelpGroup.Help.toString) + @HelpMessage("Show options for WebAssembly") + @Name("wasmHelp") + @Tag(tags.implementation) + @Tag(tags.inShortHelp) + helpWasm: Boolean = false ) { private def printHelpWithGroup(help: Help[?], helpFormat: HelpFormat, group: String): Nothing = { @@ -68,6 +74,7 @@ case class HelpGroupOptions( def maybePrintGroupHelp(help: Help[?], helpFormat: HelpFormat): Unit = { if (helpJs) printHelpWithGroup(help, helpFormat, HelpGroup.ScalaJs.toString) else if (helpNative) printHelpWithGroup(help, helpFormat, HelpGroup.ScalaNative.toString) + else if (helpWasm) printHelpWithGroup(help, helpFormat, HelpGroup.Wasm.toString) } } diff --git a/modules/cli/src/main/scala/scala/cli/commands/shared/SharedOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/shared/SharedOptions.scala index 23ef44655f..f5eadce912 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/shared/SharedOptions.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/shared/SharedOptions.scala @@ -298,13 +298,23 @@ final case class SharedOptions( ) } - private def buildWasmOptions(opts: WasmOptions): options.WasmOptions = { + private def buildWasmOptions( + opts: WasmOptions + ): Either[BuildException, options.WasmOptions] = { import opts._ - options.WasmOptions( - enabled = wasm, - runtime = - wasmRuntime.flatMap(options.WasmRuntime.parse).getOrElse(options.WasmRuntime.default), - denoVersion = denoVersion + val wasmEnabled = wasm || wasmRuntime.isDefined + val parsedRuntime: Either[BuildException, options.WasmRuntime] = + wasmRuntime.fold(Right(options.WasmRuntime.default)) { rt => + options.WasmRuntime.parse(rt).toRight { + val validValues = options.WasmRuntime.all.map(_.name).mkString(", ") + new scala.build.errors.UnrecognizedWasmRuntimeError(rt, validValues) + } + } + parsedRuntime.map(runtime => + options.WasmOptions( + enabled = wasmEnabled, + runtime = runtime + ) ) } @@ -333,7 +343,7 @@ final case class SharedOptions( } val parsedPlatform = platform.map(Platform.normalize).flatMap(Platform.parse) // WASM mode requires Scala.js platform for compilation - val wasmEnabled = wasmOptions.wasm + val wasmEnabled = wasmOptions.wasm || wasmOptions.wasmRuntime.isDefined val platformOpt = value { (parsedPlatform, js.js, native.native, wasmEnabled) match { case (Some(p: Platform.JS.type), _, false, _) => Right(Some(p)) @@ -439,7 +449,7 @@ final case class SharedOptions( ), scalaJsOptions = scalaJsOptions(js), scalaNativeOptions = snOpts, - wasmOptions = buildWasmOptions(wasmOptions), + wasmOptions = value(buildWasmOptions(wasmOptions)), javaOptions = value(scala.cli.commands.util.JvmUtils.javaOptions(jvm)), jmhOptions = scala.build.options.JmhOptions( jmhVersion = benchmarking.jmhVersion, diff --git a/modules/cli/src/main/scala/scala/cli/commands/shared/WasmOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/shared/WasmOptions.scala index d8793afaa2..a2e6251fdc 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/shared/WasmOptions.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/shared/WasmOptions.scala @@ -8,20 +8,15 @@ import scala.cli.commands.tags // format: off final case class WasmOptions( - @Group(HelpGroup.Scala.toString) + @Group(HelpGroup.Wasm.toString) @Tag(tags.experimental) @HelpMessage("Enable WebAssembly output (Scala.js WASM backend). Uses Node.js by default. To show more options for WASM pass `--help-wasm`") wasm: Boolean = false, @Group(HelpGroup.Wasm.toString) @Tag(tags.experimental) - @HelpMessage("WASM runtime to use: node (default), deno. Standalone runtimes (wasmtime, wasmedge) planned for future releases.") - wasmRuntime: Option[String] = None, - - @Group(HelpGroup.Wasm.toString) - @Tag(tags.experimental) - @HelpMessage("Version of Deno to use. If Deno is not found on PATH, it will be downloaded automatically.") - denoVersion: Option[String] = None + @HelpMessage("WASM runtime to use: node (default), deno") + wasmRuntime: Option[String] = None ) // format: on diff --git a/modules/cli/src/main/scala/scala/cli/internal/WasmRuntimeDownloader.scala b/modules/cli/src/main/scala/scala/cli/internal/WasmRuntimeDownloader.scala deleted file mode 100644 index 751e087034..0000000000 --- a/modules/cli/src/main/scala/scala/cli/internal/WasmRuntimeDownloader.scala +++ /dev/null @@ -1,104 +0,0 @@ -package scala.cli.internal - -import coursier.cache.ArchiveCache -import coursier.util.Task - -import java.util.Locale - -import scala.build.EitherCps.{either, value} -import scala.build.Logger -import scala.build.errors.BuildException -import scala.build.internal.FetchExternalBinary -import scala.util.Properties - -/** Resolves Deno binary for WASM execution. - * - * Deno is first looked up on the system PATH. If not found, it is downloaded from GitHub releases - * and cached via Coursier's ArchiveCache. - */ -object WasmRuntimeDownloader { - - /** Returns the command to run Deno. - * - * First checks system PATH, otherwise downloads the binary. - */ - def denoCommand( - version: String, - archiveCache: ArchiveCache[Task], - logger: Logger - ): Either[BuildException, Seq[String]] = either { - findOnPath("deno") match { - case Some(path) => - logger.debug(s"Using system deno at: $path") - Seq(path) - case None => - logger.message(s"Deno not found on PATH, downloading v$version...") - val binary = value(fetchDeno(version, archiveCache, logger)) - Seq(binary.toString) - } - } - - /** Find an executable on the system PATH */ - private def findOnPath(name: String): Option[String] = { - val exeName = if (Properties.isWin) s"$name.exe" else name - sys.env.get("PATH").flatMap { pathEnv => - pathEnv.split(java.io.File.pathSeparator).view.map { dir => - val file = new java.io.File(dir, exeName) - if (file.exists() && file.canExecute) Some(file.getAbsolutePath) - else None - }.find(_.isDefined).flatten - } - } - - private def detectOs(win: String, linux: String, mac: String): Either[BuildException, String] = - if (Properties.isWin) Right(win) - else if (Properties.isLinux) Right(linux) - else if (Properties.isMac) Right(mac) - else Left(new WasmRuntimeDownloadError(s"Unsupported OS: ${sys.props("os.name")}")) - - private def detectArch64(x86_64: String, aarch64: String): Either[BuildException, String] = - sys.props("os.arch").toLowerCase(Locale.ROOT) match { - case "amd64" | "x86_64" => Right(x86_64) - case "aarch64" | "arm64" => Right(aarch64) - case other => Left(new WasmRuntimeDownloadError(s"Unsupported architecture: $other")) - } - - /** Fetches Deno binary for the current platform. - * - * Deno releases are at: - * https://github.com/denoland/deno/releases/download/v{version}/deno-{platform}.zip - */ - private def fetchDeno( - version: String, - archiveCache: ArchiveCache[Task], - logger: Logger - ): Either[BuildException, os.Path] = either { - val platform = value(denoPlatform) - val url = s"https://github.com/denoland/deno/releases/download/v$version/deno-$platform.zip" - - val binaryOpt = value { - FetchExternalBinary.fetchLauncher( - url = url, - changing = false, - archiveCache = archiveCache, - logger = logger, - launcherPrefix = "deno", - launcherPathOpt = None, - makeExecutable = true - ) - } - - binaryOpt.getOrElse { - value(Left(new WasmRuntimeDownloadError(s"Could not download Deno v$version for $platform"))) - } - } - - /** Platform suffix for Deno downloads */ - private def denoPlatform: Either[BuildException, String] = either { - val arch = value(detectArch64("x86_64", "aarch64")) - val os = value(detectOs("pc-windows-msvc", "unknown-linux-gnu", "apple-darwin")) - s"$arch-$os" - } -} - -class WasmRuntimeDownloadError(message: String) extends BuildException(message) diff --git a/modules/core/src/main/scala/scala/build/errors/UnrecognizedWasmRuntimeError.scala b/modules/core/src/main/scala/scala/build/errors/UnrecognizedWasmRuntimeError.scala new file mode 100644 index 0000000000..46e2f43b6c --- /dev/null +++ b/modules/core/src/main/scala/scala/build/errors/UnrecognizedWasmRuntimeError.scala @@ -0,0 +1,4 @@ +package scala.build.errors + +class UnrecognizedWasmRuntimeError(runtime: String, validValues: String) + extends BuildException(s"Unrecognized WASM runtime: '$runtime'. Valid values: $validValues") diff --git a/modules/core/src/main/scala/scala/build/errors/UnsupportedWasmRuntimeError.scala b/modules/core/src/main/scala/scala/build/errors/UnsupportedWasmRuntimeError.scala deleted file mode 100644 index b663b6a956..0000000000 --- a/modules/core/src/main/scala/scala/build/errors/UnsupportedWasmRuntimeError.scala +++ /dev/null @@ -1,3 +0,0 @@ -package scala.build.errors - -final class UnsupportedWasmRuntimeError(message: String) extends BuildException(message) diff --git a/modules/directives/src/main/scala/scala/build/preprocessing/directives/Wasm.scala b/modules/directives/src/main/scala/scala/build/preprocessing/directives/Wasm.scala index 361fcc32ab..9a1b7f67f9 100644 --- a/modules/directives/src/main/scala/scala/build/preprocessing/directives/Wasm.scala +++ b/modules/directives/src/main/scala/scala/build/preprocessing/directives/Wasm.scala @@ -2,7 +2,7 @@ package scala.build.preprocessing.directives import scala.build.Positioned import scala.build.directives.* -import scala.build.errors.BuildException +import scala.build.errors.{BuildException, UnrecognizedWasmRuntimeError} import scala.build.options.{BuildOptions, Platform, ScalaOptions, WasmOptions, WasmRuntime} import scala.cli.commands.SpecificationLevel @@ -10,40 +10,44 @@ import scala.cli.commands.SpecificationLevel @DirectiveExamples("//> using wasm") @DirectiveExamples("//> using wasmRuntime node") @DirectiveExamples("//> using wasmRuntime deno") -@DirectiveExamples("//> using denoVersion 2.1.4") @DirectiveUsage( - "//> using wasm|wasmRuntime|denoVersion _value_", + "//> using wasm|wasmRuntime _value_", """ |`//> using wasm` _true|false_ | |`//> using wasm` | - |`//> using wasmRuntime` _node|deno|wasmtime|wasmedge|wasmer_ - | - |`//> using denoVersion` _value_ + |`//> using wasmRuntime` _node|deno_ |""".stripMargin ) @DirectiveDescription("Add WebAssembly options") @DirectiveLevel(SpecificationLevel.EXPERIMENTAL) final case class Wasm( wasm: Option[Boolean] = None, - wasmRuntime: Option[String] = None, - denoVersion: Option[String] = None + wasmRuntime: Option[String] = None ) extends HasBuildOptions { def buildOptions: Either[BuildException, BuildOptions] = { - val parsedRuntime = wasmRuntime.flatMap(WasmRuntime.parse) - val wasmOptions = WasmOptions( - enabled = wasm.getOrElse(false), - runtime = parsedRuntime.getOrElse(WasmRuntime.default), - denoVersion = denoVersion - ) - // When WASM is enabled, force Platform.JS (Scala.js WASM backend requires JS compilation) - val scalaOptions = - if (wasm.getOrElse(false)) - ScalaOptions(platform = Some(Positioned.none(Platform.JS))) - else - ScalaOptions() - Right(BuildOptions(scalaOptions = scalaOptions, wasmOptions = wasmOptions)) + val parsedRuntime = + wasmRuntime.fold(Right(WasmRuntime.default): Either[BuildException, WasmRuntime]) { rt => + WasmRuntime.parse(rt).toRight { + val validValues = WasmRuntime.all.map(_.name).mkString(", ") + new UnrecognizedWasmRuntimeError(rt, validValues) + } + } + parsedRuntime.map { runtime => + val wasmEnabled = wasm.getOrElse(false) || wasmRuntime.isDefined + val wasmOptions = WasmOptions( + enabled = wasmEnabled, + runtime = runtime + ) + // When WASM is enabled, force Platform.JS (Scala.js WASM backend requires JS compilation) + val scalaOptions = + if (wasmEnabled) + ScalaOptions(platform = Some(Positioned.none(Platform.JS))) + else + ScalaOptions() + BuildOptions(scalaOptions = scalaOptions, wasmOptions = wasmOptions) + } } } diff --git a/modules/integration/src/test/scala/scala/cli/integration/RunScalaJsTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/RunScalaJsTestDefinitions.scala index 8436bc2e0f..7ff3972f7a 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/RunScalaJsTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/RunScalaJsTestDefinitions.scala @@ -428,32 +428,6 @@ trait RunScalaJsTestDefinitions { this: RunTestDefinitions => } } - for (runtime <- Seq("wasmtime", "wasmedge", "wasmer")) - test(s"Unsupported WASM runtime '$runtime' gives clear error") { - val inputs = TestInputs( - os.rel / "Hello.scala" -> - """object Hello { - | def main(args: Array[String]): Unit = println("Hello!") - |} - |""".stripMargin - ) - inputs.fromRoot { root => - val res = os.proc( - TestUtil.cli, - "--power", - "run", - "Hello.scala", - "--wasm", - "--wasm-runtime", - runtime, - extraOptions - ).call(cwd = root, check = false, mergeErrIntoOut = true) - expect(res.exitCode != 0) - expect(res.out.trim().contains("not yet supported")) - expect(res.out.trim().contains("scala-js/scala-js/issues/4991")) - } - } - if (TestUtil.fromPath("deno").isDefined) test("Run with --wasm-runtime deno") { val inputs = TestInputs( diff --git a/modules/options/src/main/scala/scala/build/options/WasmOptions.scala b/modules/options/src/main/scala/scala/build/options/WasmOptions.scala index f96d697803..34450e2385 100644 --- a/modules/options/src/main/scala/scala/build/options/WasmOptions.scala +++ b/modules/options/src/main/scala/scala/build/options/WasmOptions.scala @@ -1,24 +1,16 @@ package scala.build.options -import scala.build.internal.Constants - /** Options for WebAssembly compilation and execution. * * @param enabled * If true, enable WASM output (Scala.js WASM backend) * @param runtime - * The WASM runtime to use for execution (node, deno, wasmtime, wasmedge, wasmer) - * @param denoVersion - * Version of Deno to download (if not found on PATH) + * The WASM runtime to use for execution (node, deno) */ final case class WasmOptions( enabled: Boolean = false, - runtime: WasmRuntime = WasmRuntime.default, - denoVersion: Option[String] = None -) { - def finalDenoVersion: String = - denoVersion.filter(_.nonEmpty).getOrElse(Constants.defaultDenoVersion) -} + runtime: WasmRuntime = WasmRuntime.default +) object WasmOptions { implicit val hasHashData: HasHashData[WasmOptions] = HasHashData.derive diff --git a/modules/options/src/main/scala/scala/build/options/WasmRuntime.scala b/modules/options/src/main/scala/scala/build/options/WasmRuntime.scala index f88f3028ab..a2e68d63f8 100644 --- a/modules/options/src/main/scala/scala/build/options/WasmRuntime.scala +++ b/modules/options/src/main/scala/scala/build/options/WasmRuntime.scala @@ -7,30 +7,14 @@ import java.util.Locale * JS-based runtimes (work now with Scala.js WASM backend): * - Node: Uses Node.js (V8 engine) with JavaScript loader * - Deno: Uses Deno (V8 engine) with ES module support - * - * Standalone runtimes (future, requires upstream Scala.js standalone WASM support): - * - Wasmtime: Primary standalone target, full WasmGC + Component Model - * - WasmEdge: Secondary standalone target, CNCF cloud-native runtime - * - Wasmer: Placeholder, no WasmGC support yet */ -sealed abstract class WasmRuntime(val name: String) { - def isJsBased: Boolean = this match { - case WasmRuntime.Node | WasmRuntime.Deno => true - case _ => false - } - def isStandalone: Boolean = !isJsBased -} +sealed abstract class WasmRuntime(val name: String) object WasmRuntime { - // JS-based runtimes (work now) case object Node extends WasmRuntime("node") case object Deno extends WasmRuntime("deno") - // Standalone runtimes (future - requires upstream Scala.js standalone WASM support) - case object Wasmtime extends WasmRuntime("wasmtime") - case object WasmEdge extends WasmRuntime("wasmedge") - case object Wasmer extends WasmRuntime("wasmer") - val all: Seq[WasmRuntime] = Seq(Node, Deno, Wasmtime, WasmEdge, Wasmer) + val all: Seq[WasmRuntime] = Seq(Node, Deno) def default: WasmRuntime = Node @@ -38,9 +22,6 @@ object WasmRuntime { s.trim.toLowerCase(Locale.ROOT) match { case "node" | "nodejs" => Some(Node) case "deno" => Some(Deno) - case "wasmtime" => Some(Wasmtime) - case "wasmedge" => Some(WasmEdge) - case "wasmer" => Some(Wasmer) case _ => None } diff --git a/website/docs/reference/cli-options.md b/website/docs/reference/cli-options.md index 0247cb83df..32926df2bd 100644 --- a/website/docs/reference/cli-options.md +++ b/website/docs/reference/cli-options.md @@ -591,6 +591,12 @@ Aliases: `--fmt-help`, `--help-fmt`, `--scalafmt-help` Show options for Scalafmt +### `--help-wasm` + +Aliases: `--wasm-help` + +Show options for WebAssembly + ## Install completions options Available in commands: @@ -1975,31 +1981,21 @@ A github token used to access GitHub. Not needed in most cases. Don't check for the newest available Scala CLI version upstream -## WebAssembly options +## Wasm options Available in commands: -[`run`](./commands.md#run), [`shebang`](./commands.md#shebang) +[`bsp`](./commands.md#bsp), [`compile`](./commands.md#compile), [`dependency-update`](./commands.md#dependency-update), [`doc`](./commands.md#doc), [`export`](./commands.md#export), [`fix`](./commands.md#fix), [`fmt` , `format` , `scalafmt`](./commands.md#fmt), [`package`](./commands.md#package), [`publish`](./commands.md#publish), [`publish local`](./commands.md#publish-local), [`repl` , `console`](./commands.md#repl), [`run`](./commands.md#run), [`setup-ide`](./commands.md#setup-ide), [`shebang`](./commands.md#shebang), [`test`](./commands.md#test) ### `--wasm` -[Experimental] - Enable WebAssembly output (Scala.js WASM backend). Uses Node.js by default. To show more options for WASM pass `--help-wasm` ### `--wasm-runtime` -[Experimental] - -WASM runtime to use: node (default), deno. Standalone runtimes (wasmtime, wasmedge) planned for future releases. - -### `--deno-version` - -[Experimental] - -Version of Deno to use. If Deno is not found on PATH, it will be downloaded automatically. +WASM runtime to use: node (default), deno ## Watch options diff --git a/website/docs/reference/commands.md b/website/docs/reference/commands.md index 5e7ccd12fb..2a4855695a 100644 --- a/website/docs/reference/commands.md +++ b/website/docs/reference/commands.md @@ -32,7 +32,7 @@ All supported types of inputs can be mixed with each other. For detailed documentation refer to our website: https://scala-cli.virtuslab.org/docs/commands/compile -Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [compile](./cli-options.md#compile-options), [coursier](./cli-options.md#coursier-options), [cross](./cli-options.md#cross-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [watch](./cli-options.md#watch-options), [workspace](./cli-options.md#workspace-options) +Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [compile](./cli-options.md#compile-options), [coursier](./cli-options.md#coursier-options), [cross](./cli-options.md#cross-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [wasm](./cli-options.md#wasm-options), [watch](./cli-options.md#watch-options), [workspace](./cli-options.md#workspace-options) ## config @@ -82,7 +82,7 @@ Accepts option groups: [config](./cli-options.md#config-options), [coursier](./c Update dependency directives in the project -Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [dependency update](./cli-options.md#dependency-update-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [workspace](./cli-options.md#workspace-options) +Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [dependency update](./cli-options.md#dependency-update-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [wasm](./cli-options.md#wasm-options), [workspace](./cli-options.md#workspace-options) ## doc @@ -96,7 +96,7 @@ All supported types of inputs can be mixed with each other. For detailed documentation refer to our website: https://scala-cli.virtuslab.org/docs/commands/doc -Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [cross](./cli-options.md#cross-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [doc](./cli-options.md#doc-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [workspace](./cli-options.md#workspace-options) +Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [cross](./cli-options.md#cross-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [doc](./cli-options.md#doc-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [wasm](./cli-options.md#wasm-options), [workspace](./cli-options.md#workspace-options) ## export @@ -118,7 +118,7 @@ The `export` sub-command is experimental. Please bear in mind that non-ideal user experience should be expected. If you encounter any bugs or have feedback to share, make sure to reach out to the maintenance team at https://github.com/VirtusLab/scala-cli -Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [export](./cli-options.md#export-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [main class](./cli-options.md#main-class-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [workspace](./cli-options.md#workspace-options) +Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [export](./cli-options.md#export-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [main class](./cli-options.md#main-class-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [wasm](./cli-options.md#wasm-options), [workspace](./cli-options.md#workspace-options) ## fix @@ -144,7 +144,7 @@ The `fix` sub-command is experimental. Please bear in mind that non-ideal user experience should be expected. If you encounter any bugs or have feedback to share, make sure to reach out to the maintenance team at https://github.com/VirtusLab/scala-cli -Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [fix](./cli-options.md#fix-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [Scalafix](./cli-options.md#scalafix-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [workspace](./cli-options.md#workspace-options) +Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [fix](./cli-options.md#fix-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [Scalafix](./cli-options.md#scalafix-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [wasm](./cli-options.md#wasm-options), [workspace](./cli-options.md#workspace-options) ## fmt @@ -161,7 +161,7 @@ All standard Scala CLI inputs are accepted, but only Scala sources will be forma For detailed documentation refer to our website: https://scala-cli.virtuslab.org/docs/commands/fmt -Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [fmt](./cli-options.md#fmt-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [workspace](./cli-options.md#workspace-options) +Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [fmt](./cli-options.md#fmt-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [wasm](./cli-options.md#wasm-options), [workspace](./cli-options.md#workspace-options) ## help @@ -213,7 +213,7 @@ All supported types of inputs can be mixed with each other. For detailed documentation refer to our website: https://scala-cli.virtuslab.org/docs/commands/repl -Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [cross](./cli-options.md#cross-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [java](./cli-options.md#java-options), [java prop](./cli-options.md#java-prop-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [repl](./cli-options.md#repl-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [watch](./cli-options.md#watch-options), [workspace](./cli-options.md#workspace-options) +Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [cross](./cli-options.md#cross-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [java](./cli-options.md#java-options), [java prop](./cli-options.md#java-prop-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [repl](./cli-options.md#repl-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [wasm](./cli-options.md#wasm-options), [watch](./cli-options.md#watch-options), [workspace](./cli-options.md#workspace-options) ## package @@ -231,7 +231,7 @@ All supported types of inputs can be mixed with each other. For detailed documentation refer to our website: https://scala-cli.virtuslab.org/docs/commands/package -Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [cross](./cli-options.md#cross-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [java](./cli-options.md#java-options), [java prop](./cli-options.md#java-prop-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [main class](./cli-options.md#main-class-options), [markdown](./cli-options.md#markdown-options), [package](./cli-options.md#package-options), [packager](./cli-options.md#packager-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [watch](./cli-options.md#watch-options), [workspace](./cli-options.md#workspace-options) +Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [cross](./cli-options.md#cross-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [java](./cli-options.md#java-options), [java prop](./cli-options.md#java-prop-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [main class](./cli-options.md#main-class-options), [markdown](./cli-options.md#markdown-options), [package](./cli-options.md#package-options), [packager](./cli-options.md#packager-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [wasm](./cli-options.md#wasm-options), [watch](./cli-options.md#watch-options), [workspace](./cli-options.md#workspace-options) ## publish @@ -258,7 +258,7 @@ The `publish` sub-command is experimental. Please bear in mind that non-ideal user experience should be expected. If you encounter any bugs or have feedback to share, make sure to reach out to the maintenance team at https://github.com/VirtusLab/scala-cli -Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [cross](./cli-options.md#cross-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [main class](./cli-options.md#main-class-options), [markdown](./cli-options.md#markdown-options), [pgp scala signing](./cli-options.md#pgp-scala-signing-options), [power](./cli-options.md#power-options), [publish](./cli-options.md#publish-options), [publish connection](./cli-options.md#publish-connection-options), [publish params](./cli-options.md#publish-params-options), [publish repository](./cli-options.md#publish-repository-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [watch](./cli-options.md#watch-options), [workspace](./cli-options.md#workspace-options) +Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [cross](./cli-options.md#cross-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [main class](./cli-options.md#main-class-options), [markdown](./cli-options.md#markdown-options), [pgp scala signing](./cli-options.md#pgp-scala-signing-options), [power](./cli-options.md#power-options), [publish](./cli-options.md#publish-options), [publish connection](./cli-options.md#publish-connection-options), [publish params](./cli-options.md#publish-params-options), [publish repository](./cli-options.md#publish-repository-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [wasm](./cli-options.md#wasm-options), [watch](./cli-options.md#watch-options), [workspace](./cli-options.md#workspace-options) ## publish local @@ -270,7 +270,7 @@ The `publish-local` sub-command is experimental. Please bear in mind that non-ideal user experience should be expected. If you encounter any bugs or have feedback to share, make sure to reach out to the maintenance team at https://github.com/VirtusLab/scala-cli -Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [cross](./cli-options.md#cross-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [main class](./cli-options.md#main-class-options), [markdown](./cli-options.md#markdown-options), [pgp scala signing](./cli-options.md#pgp-scala-signing-options), [power](./cli-options.md#power-options), [publish](./cli-options.md#publish-options), [publish local](./cli-options.md#publish-local-options), [publish params](./cli-options.md#publish-params-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [watch](./cli-options.md#watch-options), [workspace](./cli-options.md#workspace-options) +Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [cross](./cli-options.md#cross-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [main class](./cli-options.md#main-class-options), [markdown](./cli-options.md#markdown-options), [pgp scala signing](./cli-options.md#pgp-scala-signing-options), [power](./cli-options.md#power-options), [publish](./cli-options.md#publish-options), [publish local](./cli-options.md#publish-local-options), [publish params](./cli-options.md#publish-params-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [wasm](./cli-options.md#wasm-options), [watch](./cli-options.md#watch-options), [workspace](./cli-options.md#workspace-options) ## publish setup @@ -308,7 +308,7 @@ To pass arguments to the actual application, just add them after `--`, like: For detailed documentation refer to our website: https://scala-cli.virtuslab.org/docs/commands/run -Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [cross](./cli-options.md#cross-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [java](./cli-options.md#java-options), [java prop](./cli-options.md#java-prop-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [main class](./cli-options.md#main-class-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [run](./cli-options.md#run-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [watch](./cli-options.md#watch-options), [workspace](./cli-options.md#workspace-options) +Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [cross](./cli-options.md#cross-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [java](./cli-options.md#java-options), [java prop](./cli-options.md#java-prop-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [main class](./cli-options.md#main-class-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [run](./cli-options.md#run-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [wasm](./cli-options.md#wasm-options), [watch](./cli-options.md#watch-options), [workspace](./cli-options.md#workspace-options) ## github secret create @@ -355,7 +355,7 @@ Using directives can be defined in all supported input source file types. For detailed documentation refer to our website: https://scala-cli.virtuslab.org/docs/commands/setup-ide -Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [bsp file](./cli-options.md#bsp-file-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [setup IDE](./cli-options.md#setup-ide-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [workspace](./cli-options.md#workspace-options) +Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [bsp file](./cli-options.md#bsp-file-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [setup IDE](./cli-options.md#setup-ide-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [wasm](./cli-options.md#wasm-options), [workspace](./cli-options.md#workspace-options) ## shebang @@ -386,7 +386,7 @@ Using this, it is possible to conveniently set up Unix shebang scripts. For exam For detailed documentation refer to our website: https://scala-cli.virtuslab.org/docs/commands/shebang -Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [cross](./cli-options.md#cross-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [java](./cli-options.md#java-options), [java prop](./cli-options.md#java-prop-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [main class](./cli-options.md#main-class-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [run](./cli-options.md#run-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [watch](./cli-options.md#watch-options), [workspace](./cli-options.md#workspace-options) +Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [cross](./cli-options.md#cross-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [java](./cli-options.md#java-options), [java prop](./cli-options.md#java-prop-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [main class](./cli-options.md#main-class-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [run](./cli-options.md#run-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [wasm](./cli-options.md#wasm-options), [watch](./cli-options.md#watch-options), [workspace](./cli-options.md#workspace-options) ## test @@ -410,7 +410,7 @@ All supported types of inputs can be mixed with each other. For detailed documentation refer to our website: https://scala-cli.virtuslab.org/docs/commands/test -Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [cross](./cli-options.md#cross-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [java](./cli-options.md#java-options), [java prop](./cli-options.md#java-prop-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [test](./cli-options.md#test-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [watch](./cli-options.md#watch-options), [workspace](./cli-options.md#workspace-options) +Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [cross](./cli-options.md#cross-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [java](./cli-options.md#java-options), [java prop](./cli-options.md#java-prop-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [test](./cli-options.md#test-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [wasm](./cli-options.md#wasm-options), [watch](./cli-options.md#watch-options), [workspace](./cli-options.md#workspace-options) ## uninstall @@ -513,7 +513,7 @@ It is normally supposed to be invoked by your IDE when a Scala CLI project is im Detailed documentation can be found on our website: https://scala-cli.virtuslab.org -Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [bsp](./cli-options.md#bsp-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [workspace](./cli-options.md#workspace-options) +Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [bsp](./cli-options.md#bsp-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [wasm](./cli-options.md#wasm-options), [workspace](./cli-options.md#workspace-options) ### default-file diff --git a/website/docs/reference/directives.md b/website/docs/reference/directives.md index 3e2c313f34..883aa44ec0 100644 --- a/website/docs/reference/directives.md +++ b/website/docs/reference/directives.md @@ -680,27 +680,6 @@ Add Scala.js options `//> using jsEmitWasm` -### WebAssembly - -Add WebAssembly options - -`//> using wasm` _true|false_ - -`//> using wasm` - -`//> using wasmRuntime` _node|deno|wasmtime|wasmedge|wasmer_ - -`//> using denoVersion` _value_ - -#### Examples -`//> using wasm` - -`//> using wasmRuntime node` - -`//> using wasmRuntime deno` - -`//> using denoVersion 2.1.4` - ### Test framework Set the test framework @@ -728,6 +707,25 @@ Use a toolkit as dependency (not supported in Scala 2.12), 'default' version for `//> using test.toolkit default` +### WASM options + +Add WebAssembly options + + +`//> using wasm` _true|false_ + +`//> using wasm` + +`//> using wasmRuntime` _node|deno_ + + +#### Examples +`//> using wasm` + +`//> using wasmRuntime node` + +`//> using wasmRuntime deno` + ### Watch additional inputs Watch additional files or directories when using watch mode diff --git a/website/docs/reference/scala-command/cli-options.md b/website/docs/reference/scala-command/cli-options.md index c0f441455e..3b18808a99 100644 --- a/website/docs/reference/scala-command/cli-options.md +++ b/website/docs/reference/scala-command/cli-options.md @@ -498,6 +498,14 @@ Aliases: `--fmt-help`, `--help-fmt`, `--scalafmt-help` Show options for Scalafmt +### `--help-wasm` + +Aliases: `--wasm-help` + +`IMPLEMENTATION specific` per Scala Runner specification + +Show options for WebAssembly + ## Install completions options Available in commands: @@ -1451,6 +1459,16 @@ A github token used to access GitHub. Not needed in most cases. Don't check for the newest available Scala CLI version upstream +## Wasm options + +Available in commands: + +[`bsp`](./commands.md#bsp), [`compile`](./commands.md#compile), [`doc`](./commands.md#doc), [`fmt` , `format` , `scalafmt`](./commands.md#fmt), [`repl` , `console`](./commands.md#repl), [`run`](./commands.md#run), [`setup-ide`](./commands.md#setup-ide), [`shebang`](./commands.md#shebang), [`test`](./commands.md#test) + + + +*This section was automatically generated and may be empty if no options were available.* + ## Watch options Available in commands: diff --git a/website/docs/reference/scala-command/commands.md b/website/docs/reference/scala-command/commands.md index a13403a694..fc2ae80e7f 100644 --- a/website/docs/reference/scala-command/commands.md +++ b/website/docs/reference/scala-command/commands.md @@ -31,7 +31,7 @@ All supported types of inputs can be mixed with each other. For detailed documentation refer to our website: https://scala-cli.virtuslab.org/docs/commands/compile -Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [compile](./cli-options.md#compile-options), [coursier](./cli-options.md#coursier-options), [cross](./cli-options.md#cross-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [watch](./cli-options.md#watch-options), [workspace](./cli-options.md#workspace-options) +Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [compile](./cli-options.md#compile-options), [coursier](./cli-options.md#coursier-options), [cross](./cli-options.md#cross-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [wasm](./cli-options.md#wasm-options), [watch](./cli-options.md#watch-options), [workspace](./cli-options.md#workspace-options) ### config @@ -89,7 +89,7 @@ All supported types of inputs can be mixed with each other. For detailed documentation refer to our website: https://scala-cli.virtuslab.org/docs/commands/doc -Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [cross](./cli-options.md#cross-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [doc](./cli-options.md#doc-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [workspace](./cli-options.md#workspace-options) +Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [cross](./cli-options.md#cross-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [doc](./cli-options.md#doc-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [wasm](./cli-options.md#wasm-options), [workspace](./cli-options.md#workspace-options) ### repl @@ -111,7 +111,7 @@ All supported types of inputs can be mixed with each other. For detailed documentation refer to our website: https://scala-cli.virtuslab.org/docs/commands/repl -Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [cross](./cli-options.md#cross-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [java](./cli-options.md#java-options), [java prop](./cli-options.md#java-prop-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [repl](./cli-options.md#repl-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [watch](./cli-options.md#watch-options), [workspace](./cli-options.md#workspace-options) +Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [cross](./cli-options.md#cross-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [java](./cli-options.md#java-options), [java prop](./cli-options.md#java-prop-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [repl](./cli-options.md#repl-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [wasm](./cli-options.md#wasm-options), [watch](./cli-options.md#watch-options), [workspace](./cli-options.md#workspace-options) ### run @@ -137,7 +137,7 @@ To pass arguments to the actual application, just add them after `--`, like: For detailed documentation refer to our website: https://scala-cli.virtuslab.org/docs/commands/run -Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [cross](./cli-options.md#cross-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [java](./cli-options.md#java-options), [java prop](./cli-options.md#java-prop-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [main class](./cli-options.md#main-class-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [run](./cli-options.md#run-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [watch](./cli-options.md#watch-options), [workspace](./cli-options.md#workspace-options) +Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [cross](./cli-options.md#cross-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [java](./cli-options.md#java-options), [java prop](./cli-options.md#java-prop-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [main class](./cli-options.md#main-class-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [run](./cli-options.md#run-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [wasm](./cli-options.md#wasm-options), [watch](./cli-options.md#watch-options), [workspace](./cli-options.md#workspace-options) ### shebang @@ -168,7 +168,7 @@ Using this, it is possible to conveniently set up Unix shebang scripts. For exam For detailed documentation refer to our website: https://scala-cli.virtuslab.org/docs/commands/shebang -Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [cross](./cli-options.md#cross-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [java](./cli-options.md#java-options), [java prop](./cli-options.md#java-prop-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [main class](./cli-options.md#main-class-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [run](./cli-options.md#run-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [watch](./cli-options.md#watch-options), [workspace](./cli-options.md#workspace-options) +Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [cross](./cli-options.md#cross-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [java](./cli-options.md#java-options), [java prop](./cli-options.md#java-prop-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [main class](./cli-options.md#main-class-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [run](./cli-options.md#run-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [wasm](./cli-options.md#wasm-options), [watch](./cli-options.md#watch-options), [workspace](./cli-options.md#workspace-options) ## SHOULD have commands: @@ -187,7 +187,7 @@ All standard Scala CLI inputs are accepted, but only Scala sources will be forma For detailed documentation refer to our website: https://scala-cli.virtuslab.org/docs/commands/fmt -Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [fmt](./cli-options.md#fmt-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [workspace](./cli-options.md#workspace-options) +Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [fmt](./cli-options.md#fmt-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [wasm](./cli-options.md#wasm-options), [workspace](./cli-options.md#workspace-options) ### test @@ -211,7 +211,7 @@ All supported types of inputs can be mixed with each other. For detailed documentation refer to our website: https://scala-cli.virtuslab.org/docs/commands/test -Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [cross](./cli-options.md#cross-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [java](./cli-options.md#java-options), [java prop](./cli-options.md#java-prop-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [test](./cli-options.md#test-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [watch](./cli-options.md#watch-options), [workspace](./cli-options.md#workspace-options) +Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [cross](./cli-options.md#cross-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [java](./cli-options.md#java-options), [java prop](./cli-options.md#java-prop-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [test](./cli-options.md#test-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [wasm](./cli-options.md#wasm-options), [watch](./cli-options.md#watch-options), [workspace](./cli-options.md#workspace-options) ### version @@ -243,7 +243,7 @@ It is normally supposed to be invoked by your IDE when a Scala CLI project is im Detailed documentation can be found on our website: https://scala-cli.virtuslab.org -Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [bsp](./cli-options.md#bsp-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [workspace](./cli-options.md#workspace-options) +Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [bsp](./cli-options.md#bsp-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [wasm](./cli-options.md#wasm-options), [workspace](./cli-options.md#workspace-options) ### clean @@ -295,7 +295,7 @@ Using directives can be defined in all supported input source file types. For detailed documentation refer to our website: https://scala-cli.virtuslab.org/docs/commands/setup-ide -Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [bsp file](./cli-options.md#bsp-file-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [setup IDE](./cli-options.md#setup-ide-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [workspace](./cli-options.md#workspace-options) +Accepts option groups: [benchmarking](./cli-options.md#benchmarking-options), [bsp file](./cli-options.md#bsp-file-options), [compilation server](./cli-options.md#compilation-server-options), [coursier](./cli-options.md#coursier-options), [debug](./cli-options.md#debug-options), [dependency](./cli-options.md#dependency-options), [global suppress warning](./cli-options.md#global-suppress-warning-options), [help group](./cli-options.md#help-group-options), [input](./cli-options.md#input-options), [jvm](./cli-options.md#jvm-options), [logging](./cli-options.md#logging-options), [markdown](./cli-options.md#markdown-options), [power](./cli-options.md#power-options), [python](./cli-options.md#python-options), [Scala.js](./cli-options.md#scalajs-options), [Scala Native](./cli-options.md#scala-native-options), [scalac](./cli-options.md#scalac-options), [scalac extra](./cli-options.md#scalac-extra-options), [scope](./cli-options.md#scope-options), [semantic db](./cli-options.md#semantic-db-options), [setup IDE](./cli-options.md#setup-ide-options), [shared](./cli-options.md#shared-options), [snippet](./cli-options.md#snippet-options), [source generator](./cli-options.md#source-generator-options), [suppress warning](./cli-options.md#suppress-warning-options), [verbosity](./cli-options.md#verbosity-options), [version](./cli-options.md#version-options), [wasm](./cli-options.md#wasm-options), [workspace](./cli-options.md#workspace-options) ### uninstall diff --git a/website/docs/reference/scala-command/runner-specification.md b/website/docs/reference/scala-command/runner-specification.md index fbad9dc448..4051a36b98 100644 --- a/website/docs/reference/scala-command/runner-specification.md +++ b/website/docs/reference/scala-command/runner-specification.md @@ -636,6 +636,12 @@ Show options for Scalafmt Aliases: `--help-fmt` ,`--scalafmt-help` ,`--fmt-help` +**--help-wasm** + +Show options for WebAssembly + +Aliases: `--wasm-help` + **--strict-bloop-json-check** @@ -1438,6 +1444,12 @@ Show options for Scalafmt Aliases: `--help-fmt` ,`--scalafmt-help` ,`--fmt-help` +**--help-wasm** + +Show options for WebAssembly + +Aliases: `--wasm-help` + **--strict-bloop-json-check** @@ -2057,6 +2069,12 @@ Show options for Scalafmt Aliases: `--help-fmt` ,`--scalafmt-help` ,`--fmt-help` +**--help-wasm** + +Show options for WebAssembly + +Aliases: `--wasm-help` + **--strict-bloop-json-check** @@ -2706,6 +2724,12 @@ Show options for Scalafmt Aliases: `--help-fmt` ,`--scalafmt-help` ,`--fmt-help` +**--help-wasm** + +Show options for WebAssembly + +Aliases: `--wasm-help` + **--strict-bloop-json-check** @@ -3364,6 +3388,12 @@ Show options for Scalafmt Aliases: `--help-fmt` ,`--scalafmt-help` ,`--fmt-help` +**--help-wasm** + +Show options for WebAssembly + +Aliases: `--wasm-help` + **--strict-bloop-json-check** @@ -3980,6 +4010,12 @@ Show options for Scalafmt Aliases: `--help-fmt` ,`--scalafmt-help` ,`--fmt-help` +**--help-wasm** + +Show options for WebAssembly + +Aliases: `--wasm-help` + **--strict-bloop-json-check** @@ -4674,6 +4710,12 @@ Show options for Scalafmt Aliases: `--help-fmt` ,`--scalafmt-help` ,`--fmt-help` +**--help-wasm** + +Show options for WebAssembly + +Aliases: `--wasm-help` + **--strict-bloop-json-check** @@ -5378,6 +5420,12 @@ Show options for Scalafmt Aliases: `--help-fmt` ,`--scalafmt-help` ,`--fmt-help` +**--help-wasm** + +Show options for WebAssembly + +Aliases: `--wasm-help` + **--strict-bloop-json-check** @@ -6365,6 +6413,12 @@ Show options for Scalafmt Aliases: `--help-fmt` ,`--scalafmt-help` ,`--fmt-help` +**--help-wasm** + +Show options for WebAssembly + +Aliases: `--wasm-help` + **--strict-bloop-json-check** From 721bbcfbc034a60626b3e17507fbc43bd7c67c95 Mon Sep 17 00:00:00 2001 From: lostflydev Date: Sun, 19 Apr 2026 12:57:10 +0500 Subject: [PATCH 64/64] - Add Bun as a third WASM runtime (--wasm-runtime bun / //> using wasmRuntime bun) - Add BunNotFoundError with install hint - Add integration test for Bun (conditional on bun being on PATH) - Add actions/setup-node@v6 node-version:24 to all Linux integration test jobs: the default Node.js on ubuntu-24.04 runners is too old for Scala.js WASM GC (which requires Node.js >= 22). Matches docs-tests job which already pins node-version: 24 --- .github/workflows/ci.yml | 51 +++++++++++++++++++ .../scala/scala/build/internal/Runner.scala | 49 ++++++++++++++++++ .../scala/scala/cli/commands/run/Run.scala | 9 ++++ .../cli/commands/shared/WasmOptions.scala | 2 +- .../scala/build/errors/BunNotFoundError.scala | 5 ++ .../build/preprocessing/directives/Wasm.scala | 3 +- .../RunScalaJsTestDefinitions.scala | 24 +++++++++ .../scala/build/options/WasmRuntime.scala | 5 +- website/docs/reference/cli-options.md | 2 +- website/docs/reference/directives.md | 4 +- 10 files changed, 149 insertions(+), 5 deletions(-) create mode 100644 modules/core/src/main/scala/scala/build/errors/BunNotFoundError.scala diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6e26a5489..2e9923e7b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -158,6 +158,9 @@ jobs: if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: JVM integration tests if: env.SHOULD_RUN == 'true' run: ./mill -i integration.test.jvmBootstrapped @@ -196,6 +199,9 @@ jobs: if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: JVM integration tests if: env.SHOULD_RUN == 'true' run: ./mill -i integration.test.jvm @@ -234,6 +240,9 @@ jobs: if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: JVM integration tests if: env.SHOULD_RUN == 'true' run: ./mill -i integration.test.jvm @@ -272,6 +281,9 @@ jobs: if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: JVM integration tests if: env.SHOULD_RUN == 'true' run: ./mill -i integration.test.jvm @@ -310,6 +322,9 @@ jobs: if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: JVM integration tests if: env.SHOULD_RUN == 'true' run: ./mill -i integration.test.jvm @@ -348,6 +363,9 @@ jobs: if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: JVM integration tests if: env.SHOULD_RUN == 'true' run: ./mill -i integration.test.jvm @@ -436,6 +454,9 @@ jobs: with: name: linux-launchers path: artifacts/ + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: Native integration tests if: env.SHOULD_RUN == 'true' run: ./mill -i nativeIntegrationTests @@ -482,6 +503,9 @@ jobs: with: name: linux-launchers path: artifacts/ + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: Native integration tests if: env.SHOULD_RUN == 'true' run: ./mill -i nativeIntegrationTests @@ -528,6 +552,9 @@ jobs: with: name: linux-launchers path: artifacts/ + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: Native integration tests if: env.SHOULD_RUN == 'true' run: ./mill -i nativeIntegrationTests @@ -574,6 +601,9 @@ jobs: with: name: linux-launchers path: artifacts/ + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: Native integration tests if: env.SHOULD_RUN == 'true' run: ./mill -i nativeIntegrationTests @@ -620,6 +650,9 @@ jobs: with: name: linux-launchers path: artifacts/ + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: Native integration tests if: env.SHOULD_RUN == 'true' run: ./mill -i nativeIntegrationTests @@ -713,6 +746,9 @@ jobs: with: name: linux-aarch64-launchers path: artifacts/ + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: Native integration tests if: env.SHOULD_RUN == 'true' run: ./mill -i nativeIntegrationTests @@ -759,6 +795,9 @@ jobs: with: name: linux-aarch64-launchers path: artifacts/ + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: Native integration tests if: env.SHOULD_RUN == 'true' run: ./mill -i nativeIntegrationTests @@ -1417,6 +1456,9 @@ jobs: - name: Build slim docker image if: env.SHOULD_RUN == 'true' run: .github/scripts/generate-slim-docker-image.sh + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: Native integration tests if: env.SHOULD_RUN == 'true' run: ./mill -i integration.test.nativeMostlyStatic @@ -1475,6 +1517,9 @@ jobs: with: name: mostly-static-launchers path: artifacts/ + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: Native integration tests if: env.SHOULD_RUN == 'true' run: ./mill -i integration.test.nativeMostlyStatic @@ -1563,6 +1608,9 @@ jobs: - name: Build docker image if: env.SHOULD_RUN == 'true' run: .github/scripts/generate-docker-image.sh + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: Native integration tests if: env.SHOULD_RUN == 'true' run: ./mill -i integration.test.nativeStatic @@ -1624,6 +1672,9 @@ jobs: - name: Build docker image if: env.SHOULD_RUN == 'true' run: .github/scripts/generate-docker-image.sh + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: Native integration tests if: env.SHOULD_RUN == 'true' run: ./mill -i integration.test.nativeStatic diff --git a/modules/build/src/main/scala/scala/build/internal/Runner.scala b/modules/build/src/main/scala/scala/build/internal/Runner.scala index 3858b47d28..cfe4cb742d 100644 --- a/modules/build/src/main/scala/scala/build/internal/Runner.scala +++ b/modules/build/src/main/scala/scala/build/internal/Runner.scala @@ -435,6 +435,55 @@ object Runner { } } + def bunCommand( + entrypoint: File, + args: Seq[String] + ): Seq[String] = { + val bunPath = findInPath("bun").fold("bun")(_.toString) + Seq(bunPath, "run", entrypoint.getAbsolutePath) ++ args + } + + def runBun( + entrypoint: File, + args: Seq[String], + logger: Logger, + allowExecve: Boolean = false + ): Either[BuildException, Process] = either { + val bunPath: String = + value(findInPath("bun") + .map(_.toString) + .toRight(BunNotFoundError())) + + val command = Seq(bunPath, "run", entrypoint.getAbsolutePath) ++ args + + if (allowExecve && Execve.available()) { + logger.log( + s"Running ${command.mkString(" ")}", + " Running" + System.lineSeparator() + + command.iterator.map(_ + System.lineSeparator()).mkString + ) + + logger.debug("execve available") + Execve.execve( + command.head, + "bun" +: command.tail.toArray, + sys.env.toArray.sorted.map { case (k, v) => s"$k=$v" } + ) + sys.error("should not happen") + } + else { + logger.log( + s"Running ${command.mkString(" ")}", + " Running" + System.lineSeparator() + + command.iterator.map(_ + System.lineSeparator()).mkString + ) + + new ProcessBuilder(command*) + .inheritIO() + .start() + } + } + def runNative( launcher: File, args: Seq[String], diff --git a/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala b/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala index c8253ee14e..e7a4be4161 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala @@ -508,6 +508,8 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers { Left(Runner.denoCommand(outputPath.toIO, args)) case WasmRuntime.Node => Left(Runner.jsCommand(outputPath.toIO, args, jsDom = false, emitWasm = true)) + case WasmRuntime.Bun => + Left(Runner.bunCommand(outputPath.toIO, args)) } else { val process = value { @@ -531,6 +533,13 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers { esModule = esModule, emitWasm = true ) + case WasmRuntime.Bun => + Runner.runBun( + outputPath.toIO, + args, + logger, + allowExecve = effectiveAllowExecve + ) } } process.onExit().thenApply(_ => if os.exists(jsDest) then os.remove(jsDest)) diff --git a/modules/cli/src/main/scala/scala/cli/commands/shared/WasmOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/shared/WasmOptions.scala index a2e6251fdc..da99420d95 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/shared/WasmOptions.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/shared/WasmOptions.scala @@ -15,7 +15,7 @@ final case class WasmOptions( @Group(HelpGroup.Wasm.toString) @Tag(tags.experimental) - @HelpMessage("WASM runtime to use: node (default), deno") + @HelpMessage("WASM runtime to use: node (default), deno, bun") wasmRuntime: Option[String] = None ) // format: on diff --git a/modules/core/src/main/scala/scala/build/errors/BunNotFoundError.scala b/modules/core/src/main/scala/scala/build/errors/BunNotFoundError.scala new file mode 100644 index 0000000000..b6665fef28 --- /dev/null +++ b/modules/core/src/main/scala/scala/build/errors/BunNotFoundError.scala @@ -0,0 +1,5 @@ +package scala.build.errors + +final class BunNotFoundError extends BuildException( + "Bun was not found on the PATH. Install Bun from https://bun.sh/ or use --wasm-runtime node" + ) \ No newline at end of file diff --git a/modules/directives/src/main/scala/scala/build/preprocessing/directives/Wasm.scala b/modules/directives/src/main/scala/scala/build/preprocessing/directives/Wasm.scala index 9a1b7f67f9..d8c959f6fb 100644 --- a/modules/directives/src/main/scala/scala/build/preprocessing/directives/Wasm.scala +++ b/modules/directives/src/main/scala/scala/build/preprocessing/directives/Wasm.scala @@ -10,6 +10,7 @@ import scala.cli.commands.SpecificationLevel @DirectiveExamples("//> using wasm") @DirectiveExamples("//> using wasmRuntime node") @DirectiveExamples("//> using wasmRuntime deno") +@DirectiveExamples("//> using wasmRuntime bun") @DirectiveUsage( "//> using wasm|wasmRuntime _value_", """ @@ -17,7 +18,7 @@ import scala.cli.commands.SpecificationLevel | |`//> using wasm` | - |`//> using wasmRuntime` _node|deno_ + |`//> using wasmRuntime` _node|deno|bun_ |""".stripMargin ) @DirectiveDescription("Add WebAssembly options") diff --git a/modules/integration/src/test/scala/scala/cli/integration/RunScalaJsTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/RunScalaJsTestDefinitions.scala index 7ff3972f7a..962091e74f 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/RunScalaJsTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/RunScalaJsTestDefinitions.scala @@ -452,6 +452,30 @@ trait RunScalaJsTestDefinitions { this: RunTestDefinitions => } } + if (TestUtil.fromPath("bun").isDefined) + test("Run with --wasm-runtime bun") { + val inputs = TestInputs( + os.rel / "Hello.scala" -> + """object Hello { + | def main(args: Array[String]): Unit = println("Hello from Bun WASM!") + |} + |""".stripMargin + ) + inputs.fromRoot { root => + val output = os.proc( + TestUtil.cli, + "--power", + "run", + "Hello.scala", + "--wasm", + "--wasm-runtime", + "bun", + extraOptions + ).call(cwd = root).out.trim() + expect(output == "Hello from Bun WASM!") + } + } + test("WASM multiple source files") { val inputs = TestInputs( os.rel / "Greeter.scala" -> diff --git a/modules/options/src/main/scala/scala/build/options/WasmRuntime.scala b/modules/options/src/main/scala/scala/build/options/WasmRuntime.scala index a2e68d63f8..b5f8814e5d 100644 --- a/modules/options/src/main/scala/scala/build/options/WasmRuntime.scala +++ b/modules/options/src/main/scala/scala/build/options/WasmRuntime.scala @@ -7,14 +7,16 @@ import java.util.Locale * JS-based runtimes (work now with Scala.js WASM backend): * - Node: Uses Node.js (V8 engine) with JavaScript loader * - Deno: Uses Deno (V8 engine) with ES module support + * - Bun: Uses Bun (JavaScriptCore engine) with ES module support */ sealed abstract class WasmRuntime(val name: String) object WasmRuntime { case object Node extends WasmRuntime("node") case object Deno extends WasmRuntime("deno") + case object Bun extends WasmRuntime("bun") - val all: Seq[WasmRuntime] = Seq(Node, Deno) + val all: Seq[WasmRuntime] = Seq(Node, Deno, Bun) def default: WasmRuntime = Node @@ -22,6 +24,7 @@ object WasmRuntime { s.trim.toLowerCase(Locale.ROOT) match { case "node" | "nodejs" => Some(Node) case "deno" => Some(Deno) + case "bun" => Some(Bun) case _ => None } diff --git a/website/docs/reference/cli-options.md b/website/docs/reference/cli-options.md index 32926df2bd..0b45ff4814 100644 --- a/website/docs/reference/cli-options.md +++ b/website/docs/reference/cli-options.md @@ -1995,7 +1995,7 @@ Enable WebAssembly output (Scala.js WASM backend). Uses Node.js by default. To s ### `--wasm-runtime` -WASM runtime to use: node (default), deno +WASM runtime to use: node (default), deno, bun ## Watch options diff --git a/website/docs/reference/directives.md b/website/docs/reference/directives.md index 883aa44ec0..c3466ccd14 100644 --- a/website/docs/reference/directives.md +++ b/website/docs/reference/directives.md @@ -716,7 +716,7 @@ Add WebAssembly options `//> using wasm` -`//> using wasmRuntime` _node|deno_ +`//> using wasmRuntime` _node|deno|bun_ #### Examples @@ -726,6 +726,8 @@ Add WebAssembly options `//> using wasmRuntime deno` +`//> using wasmRuntime bun` + ### Watch additional inputs Watch additional files or directories when using watch mode