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/.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/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/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc653938c1..2e9923e7b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,265 +13,413 @@ 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" + - uses: actions/setup-node@v6 + with: + node-version: 24 - 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" + - uses: actions/setup-node@v6 + with: + node-version: 24 - 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" + - uses: actions/setup-node@v6 + with: + node-version: 24 - 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" + - uses: actions/setup-node@v6 + with: + node-version: 24 - 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" + - uses: actions/setup-node@v6 + with: + node-version: 24 - 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" + - uses: actions/setup-node@v6 + with: + node-version: 24 - 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 +427,38 @@ 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/ + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: Native integration tests + if: env.SHOULD_RUN == 'true' run: ./mill -i nativeIntegrationTests env: UPDATE_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -305,35 +466,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 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/ + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: Native integration tests + if: env.SHOULD_RUN == 'true' run: ./mill -i nativeIntegrationTests env: UPDATE_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -341,35 +515,48 @@ 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/ + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: Native integration tests + if: env.SHOULD_RUN == 'true' run: ./mill -i nativeIntegrationTests env: UPDATE_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -377,35 +564,48 @@ 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/ + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: Native integration tests + if: env.SHOULD_RUN == 'true' run: ./mill -i nativeIntegrationTests env: UPDATE_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -413,35 +613,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 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/ + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: Native integration tests + if: env.SHOULD_RUN == 'true' run: ./mill -i nativeIntegrationTests env: UPDATE_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -449,42 +662,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 +719,38 @@ 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/ + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: Native integration tests + if: env.SHOULD_RUN == 'true' run: ./mill -i nativeIntegrationTests env: UPDATE_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -518,35 +758,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 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/ + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: Native integration tests + if: env.SHOULD_RUN == 'true' run: ./mill -i nativeIntegrationTests env: UPDATE_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -554,40 +807,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 +862,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 +901,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 +950,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 +1005,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 +1044,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 +1093,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 +1142,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 +1205,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 +1256,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 +1317,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 +1378,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 +1426,41 @@ 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 + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: Native integration tests + if: env.SHOULD_RUN == 'true' run: ./mill -i integration.test.nativeMostlyStatic env: UPDATE_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -1052,47 +1468,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-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') - uses: docker/login-action@v3 + 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/ + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: Native integration tests + if: env.SHOULD_RUN == 'true' run: ./mill -i integration.test.nativeMostlyStatic env: UPDATE_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -1100,36 +1529,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 +1578,41 @@ 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 + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: Native integration tests + if: env.SHOULD_RUN == 'true' run: ./mill -i integration.test.nativeStatic env: UPDATE_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -1165,49 +1620,63 @@ 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') - uses: docker/login-action@v3 + 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 + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: Native integration tests + if: env.SHOULD_RUN == 'true' run: ./mill -i integration.test.nativeStatic env: UPDATE_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -1215,71 +1684,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 +1784,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 +1837,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 +1943,7 @@ jobs: publish: needs: + - changes - unit-tests - jvm-bootstrapped-tests-default - jvm-tests-default @@ -1456,7 +2008,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 }} @@ -1472,6 +2024,7 @@ jobs: launchers: timeout-minutes: 20 needs: + - changes - unit-tests - jvm-bootstrapped-tests-default - jvm-tests-default @@ -1574,6 +2127,7 @@ jobs: update-packages: name: Update packages needs: + - changes - launchers - publish runs-on: ubuntu-24.04 @@ -1624,7 +2178,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 }} @@ -1669,6 +2223,7 @@ jobs: update-windows-packages: name: Update Windows packages needs: + - changes - launchers - publish runs-on: "windows-2025" 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 }} 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' diff --git a/.mill-version b/.mill-version index 45a1b3f445..e25d8d9f35 100644 --- a/.mill-version +++ b/.mill-version @@ -1 +1 @@ -1.1.2 +1.1.5 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/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..ae1a1e5508 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,168 @@ +# 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 + ↑ + directives-parser +``` + +### 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-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. | +| `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 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 + +> **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/CONTRIBUTING.md b/CONTRIBUTING.md index 08fa9c9af0..9f297b2938 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 @@ -54,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; 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 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 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/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 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. diff --git a/build.mill b/build.mill index f98bd81673..e43785f8f7 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 @@ -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) @@ -112,6 +114,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 @@ -312,9 +316,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" @@ -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()}" @@ -528,6 +541,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}" @@ -604,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) @@ -618,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]] = @@ -668,7 +682,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 @@ -699,7 +713,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") @@ -748,7 +762,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 @@ -794,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 { @@ -1012,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 @@ -1020,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 @@ -1322,6 +1348,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 @@ -1356,7 +1392,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() } @@ -1848,6 +1884,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/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/mill.bat b/mill.bat index ef5140a0aa..948392f36d 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.5" ) if [!MILL_GITHUB_RELEASE_CDN!]==[] ( set "MILL_GITHUB_RELEASE_CDN=" ) diff --git a/millw b/millw index bc04bdcd12..155baa79d7 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.5"; fi if [ -z "${GITHUB_RELEASE_CDN}" ] ; then GITHUB_RELEASE_CDN=""; fi diff --git a/modules/build/src/main/scala/scala/build/Build.scala b/modules/build/src/main/scala/scala/build/Build.scala index c631c88ea0..1a52b9bc6d 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() @@ -1092,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( @@ -1186,6 +1198,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/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/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/main/scala/scala/build/internal/Runner.scala b/modules/build/src/main/scala/scala/build/internal/Runner.scala index 2309922eb2..cfe4cb742d 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], @@ -186,6 +189,57 @@ 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.forall(_ < 2) // true if unknown or < 2 + private def endsWithCaseInsensitive(s: String, suffix: String): Boolean = s.length >= suffix.length && s.regionMatches(true, s.length - suffix.length, suffix, 0, suffix.length) @@ -218,11 +272,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. @@ -239,14 +295,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(" ")}", @@ -262,12 +320,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( @@ -304,6 +375,115 @@ object Runner { } } + def denoCommand( + entrypoint: File, + args: Seq[String] + ): Seq[String] = { + val denoPath = 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 + ): Either[BuildException, Process] = either { + 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") + 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 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], @@ -346,15 +526,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 +563,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 +633,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 +663,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 +681,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 @@ -539,8 +728,8 @@ object Runner { |""".stripMargin ) - if finalTestFrameworks.isEmpty then Left(new NoFrameworkFoundByBridgeError) - else runTests(classPath, finalTestFrameworks, requireTests, args, parentInspector) + if finalTestFrameworks.isEmpty then Left(new NoFrameworkFoundByNativeBridgeError) + else runTests(classPath, finalTestFrameworks, requireTests, args, parentInspector, logger) } finally if adapter != null then adapter.close() 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/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/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/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/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/build/src/main/scala/scala/build/preprocessing/directives/DirectivesPreprocessingUtils.scala b/modules/build/src/main/scala/scala/build/preprocessing/directives/DirectivesPreprocessingUtils.scala index 96c9edc30a..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 @@ -31,7 +31,9 @@ object DirectivesPreprocessingUtils { directives.ScalaNative.handler, directives.ScalaVersion.handler, directives.Sources.handler, - directives.Tests.handler + directives.Watching.handler, + directives.Tests.handler, + directives.Wasm.handler ).map(_.mapE(_.buildOptions)) val usingDirectiveWithReqsHandlers 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/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/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/FrameworkDiscoveryTests.scala b/modules/build/src/test/scala/scala/build/tests/FrameworkDiscoveryTests.scala new file mode 100644 index 0000000000..007c35341d --- /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, Logger as TestRunnerLogger} + +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), TestRunnerLogger(0)) + 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/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/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/build/src/test/scala/scala/build/tests/PackagingUsingDirectiveTests.scala b/modules/build/src/test/scala/scala/build/tests/PackagingUsingDirectiveTests.scala index 9b0656cebf..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,6 +34,60 @@ class PackagingUsingDirectiveTests extends TestUtil.ScalaCliBuildSuite { } } + test("graalvm packaging jvmId") { + 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("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/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..530f52f080 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,50 @@ 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 + 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 { override def log(diagnostics: Seq[Diagnostic]): Unit = { diagnostics.foreach { d => @@ -88,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/java/scala/cli/internal/PPrintStringPrefixSubst.java b/modules/cli/src/main/java/scala/cli/internal/PPrintStringPrefixSubst.java deleted file mode 100644 index 8e1f3c7dbd..0000000000 --- a/modules/cli/src/main/java/scala/cli/internal/PPrintStringPrefixSubst.java +++ /dev/null @@ -1,17 +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) { - String name = (new PPrintStringPrefixHelper()).apply((scala.collection.Iterable) i); - return name; - } - -} 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 dbfa275e9c..bbbd084eea 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} @@ -192,7 +193,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)) @@ -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 @@ -390,6 +400,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 +424,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/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/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/doc/Doc.scala b/modules/cli/src/main/scala/scala/cli/commands/doc/Doc.scala index 521c53c72a..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) @@ -125,16 +193,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 +249,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/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/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/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/package0/Package.scala b/modules/cli/src/main/scala/scala/cli/commands/package0/Package.scala index 6fecd3c98d..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 @@ -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,78 @@ 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 + } + + /** 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], + 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 +265,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 +358,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] = @@ -432,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 ) @@ -846,7 +924,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/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/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 c832c9daf7..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 @@ -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, @@ -253,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, @@ -276,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, @@ -306,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, @@ -339,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, @@ -360,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, @@ -416,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, @@ -563,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, @@ -601,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 @@ -684,6 +705,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], @@ -738,7 +761,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 8355c2df8c..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 @@ -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") ) @@ -32,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) @@ -68,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, @@ -78,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 f131f33a73..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,14 +23,27 @@ final case class PublishLocalOptions( sharedPublish: SharedPublishOptions = SharedPublishOptions(), @Recurse scalaSigning: PgpScalaSigningOptions = PgpScalaSigningOptions(), -) extends HasSharedOptions + + @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 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/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/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/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/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/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/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..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 @@ -18,7 +18,7 @@ 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 @@ -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( @@ -474,228 +474,304 @@ 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 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 { + val wasmOpts = build.options.wasmOptions + + // Check if WASM mode is requested + if wasmOpts.enabled then { + 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 + runtime match { + case WasmRuntime.Deno => + 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 { + runtime match { + case WasmRuntime.Deno => + Runner.runDeno( + outputPath.toIO, + args, + logger, + allowExecve = effectiveAllowExecve, + emitWasm = true + ) + case WasmRuntime.Node => Runner.runJs( outputPath.toIO, args, logger, allowExecve = effectiveAllowExecve, - jsDom = jsDom, + jsDom = false, sourceMap = build.options.scalaJsOptions.emitSourceMaps, - esModule = esModule + 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)) - 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 - } - // 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 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 + process.onExit().thenApply(_ => if os.exists(jsDest) then os.remove(jsDest)) + Right((process, None)) + } + } + value(res) + } + 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 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" + 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/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/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/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/ScalacOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/shared/ScalacOptions.scala index 5254989b57..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 @@ -70,6 +70,7 @@ object ScalacOptions { "g", "language", "opt", + "opt-inline", "pagewidth", "page-width", "target", @@ -107,21 +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", "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/main/scala/scala/cli/commands/shared/SharedOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/shared/SharedOptions.scala index fe4b8903d9..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 @@ -57,6 +57,8 @@ final case class SharedOptions( js: ScalaJsOptions = ScalaJsOptions(), @Recurse native: ScalaNativeOptions = ScalaNativeOptions(), + @Recurse + wasmOptions: WasmOptions = WasmOptions(), @Recurse compilationServer: SharedCompilationServerOptions = SharedCompilationServerOptions(), @Recurse @@ -211,7 +213,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 @@ -283,6 +298,26 @@ final case class SharedOptions( ) } + private def buildWasmOptions( + opts: WasmOptions + ): Either[BuildException, options.WasmOptions] = { + import opts._ + 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 + ) + ) + } + lazy val scalacOptionsFromFiles: List[String] = scalac.argsFiles.flatMap(argFile => ArgSplitter.splitToArgs(os.read(os.Path(argFile.file, os.pwd))) @@ -290,7 +325,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") @@ -304,21 +342,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 || wasmOptions.wasmRuntime.isDefined + 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) = @@ -405,6 +449,7 @@ final case class SharedOptions( ), scalaJsOptions = scalaJsOptions(js), scalaNativeOptions = snOpts, + wasmOptions = value(buildWasmOptions(wasmOptions)), javaOptions = value(scala.cli.commands.util.JvmUtils.javaOptions(jvm)), jmhOptions = scala.build.options.JmhOptions( jmhVersion = benchmarking.jmhVersion, @@ -441,7 +486,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/shared/WasmOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/shared/WasmOptions.scala new file mode 100644 index 0000000000..da99420d95 --- /dev/null +++ b/modules/cli/src/main/scala/scala/cli/commands/shared/WasmOptions.scala @@ -0,0 +1,27 @@ +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.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, bun") + wasmRuntime: 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/commands/test/Test.scala b/modules/cli/src/main/scala/scala/cli/commands/test/Test.scala index c1d528a530..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 @@ -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 = @@ -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/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/internal/PPrintStringPrefixHelper.scala b/modules/cli/src/main/scala/scala/cli/internal/PPrintStringPrefixHelper.scala deleted file mode 100644 index c8f92ec3dd..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[Object]): String = - i.collectionClassName -} 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/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..62b8fffb15 --- /dev/null +++ b/modules/cli/src/test/scala/cli/commands/tests/DocTests.scala @@ -0,0 +1,81 @@ +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) + 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/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/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"""