diff --git a/.github/workflows/lsp-public-ci.yml b/.github/workflows/lsp-public-ci.yml new file mode 100644 index 000000000..56b10026b --- /dev/null +++ b/.github/workflows/lsp-public-ci.yml @@ -0,0 +1,90 @@ +name: Public LSP workspace +on: + pull_request: + paths: + - 'crates/terraphim_lsp/**' + - 'crates/terraphim_lsp_core/**' + - 'scripts/lsp_crates_release.py' + - 'tests/test_lsp_crates_release.py' + - 'release/lsp-crates.json' + - '.github/workflows/lsp-public-ci.yml' + - 'Cargo.toml' + - '.cargo/**' + push: + branches: [main, 'codex/**'] + paths: + - 'crates/terraphim_lsp/**' + - 'crates/terraphim_lsp_core/**' + - 'scripts/lsp_crates_release.py' + - 'tests/test_lsp_crates_release.py' + - 'release/lsp-crates.json' + - '.github/workflows/lsp-public-ci.yml' + - 'Cargo.toml' + - '.cargo/**' +permissions: + contents: read +concurrency: + group: public-lsp-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +env: + PYTHONDONTWRITEBYTECODE: '1' + CARGO_TERM_COLOR: never +jobs: + qualify: + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: '3.12' + - name: Verify standalone workspace and public registry lock + id: source + run: python3 scripts/lsp_crates_release.py check-source --receipt "$RUNNER_TEMP/source-contract.json" + - name: Install reviewed Rust toolchain + env: + TOOLCHAIN: ${{ steps.source.outputs.toolchain }} + run: | + rustup toolchain install "$TOOLCHAIN" --profile minimal --component rustfmt --component clippy + rustup target add wasm32-unknown-unknown --toolchain "$TOOLCHAIN" + - name: Qualify native server, shared core and WASM + env: + TOOLCHAIN: ${{ steps.source.outputs.toolchain }} + MANIFEST: ${{ steps.source.outputs.manifest }} + CARGO_HOME: ${{ runner.temp }}/lsp-cargo + CARGO_TARGET_DIR: ${{ runner.temp }}/lsp-target + CARGO_BUILD_BUILD_DIR: ${{ runner.temp }}/lsp-build + RUSTC_WRAPPER: '' + RUSTC_WORKSPACE_WRAPPER: '' + run: | + set -euo pipefail + cargo +"$TOOLCHAIN" fmt --manifest-path "$MANIFEST" --all --check + cargo +"$TOOLCHAIN" clippy --locked --manifest-path "$MANIFEST" --workspace --all-targets -- -D warnings + cargo +"$TOOLCHAIN" test --locked --manifest-path "$MANIFEST" --workspace --all-targets + cargo +"$TOOLCHAIN" test --locked --manifest-path "$MANIFEST" --workspace --doc + cargo +"$TOOLCHAIN" check --locked --manifest-path "$MANIFEST" -p terraphim_lsp_core --target wasm32-unknown-unknown --lib + cargo +"$TOOLCHAIN" package --locked --manifest-path "$MANIFEST" -p terraphim_lsp_core + cargo +"$TOOLCHAIN" package --locked --manifest-path "$MANIFEST" -p terraphim_lsp --list > "$RUNNER_TEMP/server-package-files.txt" + python3 - <<'PYCODE' + import importlib.util, os + from pathlib import Path + spec = importlib.util.spec_from_file_location("release", "scripts/lsp_crates_release.py") + r = importlib.util.module_from_spec(spec); spec.loader.exec_module(r) + root = Path.cwd(); plan = r.load_plan(root); core = plan["packages"][0] + archive = r.package_archive(root, core, os.environ) + r.unpack_package(archive, core, r.git(root, "rev-parse", "HEAD"), Path(os.environ["RUNNER_TEMP"]) / "unpacked", root) + PYCODE + core_version="$(python3 -c 'import json; print(json.load(open("release/lsp-crates.json"))["packages"][0]["version"])')" + cd "$RUNNER_TEMP/unpacked/terraphim_lsp_core-$core_version" + cargo +"$TOOLCHAIN" test --locked --all-targets + cargo +"$TOOLCHAIN" test --locked --doc + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + if: always() + with: + name: lsp-source-contract-${{ github.sha }} + path: | + ${{ runner.temp }}/source-contract.json + ${{ runner.temp }}/server-package-files.txt + if-no-files-found: error diff --git a/.github/workflows/lsp-publish.yml b/.github/workflows/lsp-publish.yml index 5a489217b..872c21081 100644 --- a/.github/workflows/lsp-publish.yml +++ b/.github/workflows/lsp-publish.yml @@ -1,26 +1,171 @@ -name: Publish reviewed LSP handoff - +name: Build and publish public-registry LSP binaries on: - push: - tags: - - 'terraphim_lsp-v*' - + workflow_call: + inputs: + tag: + required: true + type: string + source-sha: + required: true + type: string + toolchain: + required: true + type: string permissions: contents: read - concurrency: - group: lsp-publish-${{ github.ref_name }} + group: lsp-public-binaries-${{ inputs.tag }} cancel-in-progress: false - +env: + PYTHONDONTWRITEBYTECODE: '1' jobs: + qualify: + if: github.repository == 'terraphim/terraphim-ai' && github.ref == format('refs/tags/{0}', inputs.tag) && github.sha == inputs.source-sha + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: '3.12' + - name: Install exact Rust toolchain + env: + TOOLCHAIN: ${{ inputs.toolchain }} + run: rustup toolchain install "$TOOLCHAIN" --profile minimal --component clippy + - name: Test the actual published crate and registry lock + env: + TAG: ${{ inputs.tag }} + SOURCE: ${{ inputs.source-sha }} + TOOLCHAIN: ${{ inputs.toolchain }} + run: | + set -euo pipefail + python3 scripts/lsp_public_binary.py prepare --tag "$TAG" --source "$SOURCE" --toolchain "$TOOLCHAIN" --lane "$RUNNER_TEMP/qualification" + python3 scripts/lsp_public_binary.py qualify --tag "$TAG" --source "$SOURCE" --toolchain "$TOOLCHAIN" --lane "$RUNNER_TEMP/qualification" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + if: always() + with: + name: lsp-public-qualification-${{ github.run_attempt }} + path: | + ${{ runner.temp }}/qualification/qualification.json + ${{ runner.temp }}/qualification/source.json + ${{ runner.temp }}/qualification/failure.json + ${{ runner.temp }}/qualification/logs/* + if-no-files-found: error + linux: + needs: qualify + runs-on: ubuntu-latest + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + target: [x86_64-unknown-linux-gnu, x86_64-unknown-linux-musl, aarch64-unknown-linux-musl] + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: '3.12' + - name: Install exact Rust toolchain + env: + TOOLCHAIN: ${{ inputs.toolchain }} + TARGET: ${{ matrix.target }} + run: rustup toolchain install "$TOOLCHAIN" --profile minimal --target "$TARGET" + - name: Install reviewed cross compiler wrapper + if: endsWith(matrix.target, '-musl') + env: + TOOLCHAIN: ${{ inputs.toolchain }} + CARGO_HOME: ${{ runner.temp }}/cross-cargo + run: | + rustup run "$TOOLCHAIN" cargo install cross --locked --git https://github.com/cross-rs/cross --rev 29d00c7803f221f1b3f35e561b03792368fb8339 --root "$RUNNER_TEMP/cross-tool" + echo "$RUNNER_TEMP/cross-tool/bin" >> "$GITHUB_PATH" + - name: Build exact published registry bytes + env: + TAG: ${{ inputs.tag }} + SOURCE: ${{ inputs.source-sha }} + TOOLCHAIN: ${{ inputs.toolchain }} + TARGET: ${{ matrix.target }} + run: | + set -euo pipefail + python3 scripts/lsp_public_binary.py prepare --tag "$TAG" --source "$SOURCE" --toolchain "$TOOLCHAIN" --lane "$RUNNER_TEMP/lane" + args=() + if [[ "$TARGET" == *-musl ]]; then + image="ghcr.io/cross-rs/$TARGET:0.2.5" + docker pull "$image" + digest="$(docker image inspect "$image" --format '{{index .RepoDigests 0}}')" + args+=(--cross-image "$digest") + fi + python3 scripts/lsp_public_binary.py build --tag "$TAG" --source "$SOURCE" --toolchain "$TOOLCHAIN" --lane "$RUNNER_TEMP/lane" --target "$TARGET" "${args[@]}" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: lsp-public-${{ matrix.target }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/lane/dist/** + if-no-files-found: error + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + if: failure() + with: + name: lsp-build-failure-${{ matrix.target }}-${{ github.run_attempt }} + path: | + ${{ runner.temp }}/lane/source.json + ${{ runner.temp }}/lane/failure.json + ${{ runner.temp }}/lane/logs/* + if-no-files-found: warn + macos: + needs: qualify + runs-on: macos-14 + timeout-minutes: 90 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: '3.12' + - name: Install exact Rust toolchain + env: + TOOLCHAIN: ${{ inputs.toolchain }} + run: rustup toolchain install "$TOOLCHAIN" --profile minimal --target aarch64-apple-darwin --target x86_64-apple-darwin + - name: Build both architectures and universal binary from registry bytes + env: + TAG: ${{ inputs.tag }} + SOURCE: ${{ inputs.source-sha }} + TOOLCHAIN: ${{ inputs.toolchain }} + run: | + set -euo pipefail + python3 scripts/lsp_public_binary.py prepare --tag "$TAG" --source "$SOURCE" --toolchain "$TOOLCHAIN" --lane "$RUNNER_TEMP/lane" + for target in aarch64-apple-darwin x86_64-apple-darwin; do + python3 scripts/lsp_public_binary.py build --tag "$TAG" --source "$SOURCE" --toolchain "$TOOLCHAIN" --lane "$RUNNER_TEMP/lane" --target "$target" + done + python3 scripts/lsp_public_binary.py universal --tag "$TAG" --source "$SOURCE" --toolchain "$TOOLCHAIN" --lane "$RUNNER_TEMP/lane" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: lsp-public-macos-${{ github.run_attempt }} + path: ${{ runner.temp }}/lane/dist/** + if-no-files-found: error + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + if: failure() + with: + name: lsp-build-failure-macos-${{ github.run_attempt }} + path: | + ${{ runner.temp }}/lane/source.json + ${{ runner.temp }}/lane/failure.json + ${{ runner.temp }}/lane/logs/* + if-no-files-found: warn publish: - if: github.repository == 'terraphim/terraphim-ai' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/terraphim_lsp-v') + needs: [qualify, linux, macos] environment: lsp-releases runs-on: ubuntu-latest timeout-minutes: 30 permissions: contents: write - actions: read steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 with: @@ -29,16 +174,33 @@ jobs: persist-credentials: false - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 with: - python-version: '3.11' - - name: Verify provenance and publish immutable frozen bytes + python-version: '3.12' + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: lsp-public-qualification-${{ github.run_attempt }} + path: ${{ runner.temp }}/qualification + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + pattern: lsp-public-*-unknown-linux-*-${{ github.run_attempt }} + path: ${{ runner.temp }}/dist + merge-multiple: true + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: lsp-public-macos-${{ github.run_attempt }} + path: ${{ runner.temp }}/dist + - name: Verify six assets and publish immutable frozen bytes env: GH_TOKEN: ${{ github.token }} - TAG: ${{ github.ref_name }} - run: python3 .github/scripts/release/lsp_handoff.py publish --tag "$TAG" --receipt publication-receipt.json - - name: Preserve publication or fixed-code failure receipt + TAG: ${{ inputs.tag }} + SOURCE: ${{ inputs.source-sha }} + TOOLCHAIN: ${{ inputs.toolchain }} + run: | + set -euo pipefail + python3 scripts/lsp_public_binary.py assemble --tag "$TAG" --source "$SOURCE" --toolchain "$TOOLCHAIN" --assets "$RUNNER_TEMP/dist" --qualification "$RUNNER_TEMP/qualification" --receipt "$RUNNER_TEMP/publication-receipt.json" + python3 scripts/lsp_public_binary.py publish --tag "$TAG" --source "$SOURCE" --toolchain "$TOOLCHAIN" --assets "$RUNNER_TEMP/dist" --qualification "$RUNNER_TEMP/qualification" --receipt "$RUNNER_TEMP/publication-receipt.json" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: lsp-publication-${{ github.run_id }}-${{ github.run_attempt }} - path: publication-receipt.json + path: ${{ runner.temp }}/publication-receipt.json if-no-files-found: error diff --git a/.github/workflows/publish-crates.yml b/.github/workflows/publish-crates.yml index ede0bf0c1..03e059bcb 100644 --- a/.github/workflows/publish-crates.yml +++ b/.github/workflows/publish-crates.yml @@ -33,6 +33,29 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 + - name: Refuse packages owned by the reviewed LSP publisher + env: + REQUESTED_CRATES: ${{ inputs.crate_list }} + REQUESTED_CRATE: ${{ inputs.crate }} + shell: bash + run: | + set -euo pipefail + set -f + for crate in $REQUESTED_CRATES $REQUESTED_CRATE; do + case "$crate" in + terraphim_lsp|terraphim_lsp_core) + echo "::error::LSP packages are owned by publish-lsp-crates.yml; use an exact reviewed package tag." + exit 1 + ;; + terraphim_update|terraphim_github_runner|terraphim_gitea_runner|terraphim_rlm) + ;; + *) + echo "::error::Unsupported legacy package; use an exact allowed package name." + exit 1 + ;; + esac + done + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable with: @@ -56,18 +79,28 @@ jobs: OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} run: | TOKEN=$(op read "op://TerraphimPlatform/crates.io.token/token") - echo "token=$TOKEN" >> $GITHUB_OUTPUT + echo "token=$TOKEN" >> "$GITHUB_OUTPUT" - name: Resolve crate list id: crates + env: + REQUESTED_CRATES: ${{ inputs.crate_list }} + REQUESTED_CRATE: ${{ inputs.crate }} run: | - if [ -n "${{ inputs.crate_list }}" ]; then - echo "list=${{ inputs.crate_list }}" >> $GITHUB_OUTPUT - elif [ -n "${{ inputs.crate }}" ]; then - echo "list=${{ inputs.crate }}" >> $GITHUB_OUTPUT + set -euo pipefail + set -f + if [ -n "$REQUESTED_CRATES" ]; then + selected="$REQUESTED_CRATES" + elif [ -n "$REQUESTED_CRATE" ]; then + selected="$REQUESTED_CRATE" else - echo "list=terraphim_update terraphim_github_runner terraphim_gitea_runner terraphim_rlm terraphim_lsp" >> $GITHUB_OUTPUT + selected='terraphim_update terraphim_github_runner terraphim_gitea_runner terraphim_rlm' fi + normalized="" + for crate in $selected; do + normalized="${normalized:+$normalized }$crate" + done + printf 'list=%s\n' "$normalized" >> "$GITHUB_OUTPUT" - name: Publish crates in dependency order env: @@ -79,12 +112,12 @@ jobs: chmod +x ./scripts/publish-crates.sh for crate in $CRATE_LIST; do - ARGS="--crate $crate" + ARGS=(--crate "$crate") if [ "$DRY_RUN" = "true" ]; then - ARGS="$ARGS --dry-run" + ARGS+=(--dry-run) fi VERSION=$(grep -m1 '^version' Cargo.toml | sed 's/version = "//; s/"//') - ./scripts/publish-crates.sh $ARGS --version "$VERSION" + ./scripts/publish-crates.sh "${ARGS[@]}" --version "$VERSION" done - name: Verify published packages diff --git a/.github/workflows/publish-lsp-crates.yml b/.github/workflows/publish-lsp-crates.yml new file mode 100644 index 000000000..21921d146 --- /dev/null +++ b/.github/workflows/publish-lsp-crates.yml @@ -0,0 +1,205 @@ +name: Publish reviewed LSP crates +on: + push: + tags: ['terraphim_lsp_core-v*', 'terraphim_lsp-v*'] + workflow_dispatch: + inputs: + auth_mode: + description: 'Bootstrap is only for a crate name never published before' + required: true + default: trusted + type: choice + options: [trusted, bootstrap] +permissions: + contents: read +concurrency: + group: crates-${{ github.ref_name }} + cancel-in-progress: false +env: + PYTHONDONTWRITEBYTECODE: '1' + CARGO_TERM_COLOR: never +jobs: + qualify: + if: github.repository == 'terraphim/terraphim-ai' && startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + timeout-minutes: 45 + outputs: + crate: ${{ steps.request.outputs.crate }} + version: ${{ steps.request.outputs.version }} + toolchain: ${{ steps.request.outputs.toolchain }} + manifest: ${{ steps.request.outputs.manifest }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: '3.12' + - name: Validate tag, manifests, ownership and dependency order + id: request + env: + CARGO_TARGET_DIR: ${{ runner.temp }}/lsp-crates-target + TAG: ${{ github.ref_name }} + MODE: ${{ inputs.auth_mode || 'trusted' }} + run: python3 scripts/lsp_crates_release.py validate --tag "$TAG" --mode "$MODE" --receipt "$RUNNER_TEMP/request.json" + - name: Install pinned toolchain + env: + TOOLCHAIN: ${{ steps.request.outputs.toolchain }} + run: | + rustup toolchain install "$TOOLCHAIN" --profile minimal --component rustfmt --component clippy + rustup target add wasm32-unknown-unknown --toolchain "$TOOLCHAIN" + - name: Verify source and build the publishable package + env: + CRATE: ${{ steps.request.outputs.crate }} + VERSION: ${{ steps.request.outputs.version }} + TOOLCHAIN: ${{ steps.request.outputs.toolchain }} + MANIFEST: ${{ steps.request.outputs.manifest }} + CARGO_TARGET_DIR: ${{ runner.temp }}/lsp-crates-target + TAG: ${{ github.ref_name }} + CARGO_HOME: ${{ runner.temp }}/crates-cargo + run: | + set -euo pipefail + python3 -m unittest discover -s tests -p 'test_lsp_crates_release.py' -v + cargo +"$TOOLCHAIN" fmt --manifest-path "$MANIFEST" --all --check + cargo +"$TOOLCHAIN" clippy --locked --manifest-path "$MANIFEST" -p "$CRATE" --all-targets -- -D warnings + cargo +"$TOOLCHAIN" test --locked --manifest-path "$MANIFEST" -p "$CRATE" --all-targets + cargo +"$TOOLCHAIN" test --locked --manifest-path "$MANIFEST" -p "$CRATE" --doc + if [ "$CRATE" = "terraphim_lsp_core" ]; then + cargo +"$TOOLCHAIN" check --locked --manifest-path "$MANIFEST" -p "$CRATE" --target wasm32-unknown-unknown --lib + fi + cargo +"$TOOLCHAIN" package --locked --manifest-path "$MANIFEST" --registry crates-io -p "$CRATE" + python3 scripts/lsp_crates_release.py unpack --tag "$TAG" --unpack-dir "$RUNNER_TEMP/unpacked" --receipt "$RUNNER_TEMP/archive-validation.json" + cd "$RUNNER_TEMP/unpacked/$CRATE-$VERSION" + cargo +"$TOOLCHAIN" test --locked --manifest-path Cargo.toml --all-targets + cargo +"$TOOLCHAIN" test --locked --manifest-path Cargo.toml --doc + cd "$GITHUB_WORKSPACE" + python3 scripts/lsp_crates_release.py package --tag "$TAG" --receipt "$RUNNER_TEMP/qualified-package.json" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: crate-qualified-${{ github.run_attempt }} + path: ${{ runner.temp }}/qualified-package.json + if-no-files-found: error + publish: + needs: qualify + if: inputs.auth_mode != 'bootstrap' + environment: crates-io + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: '3.12' + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: crate-qualified-${{ github.run_attempt }} + path: ${{ runner.temp }}/qualified + - name: Reproduce qualified bytes before authentication + env: + CRATE: ${{ needs.qualify.outputs.crate }} + TOOLCHAIN: ${{ needs.qualify.outputs.toolchain }} + MANIFEST: ${{ needs.qualify.outputs.manifest }} + CARGO_TARGET_DIR: ${{ runner.temp }}/lsp-crates-target + TAG: ${{ github.ref_name }} + CARGO_HOME: ${{ runner.temp }}/crates-cargo + run: | + set -euo pipefail + python3 scripts/lsp_crates_release.py validate --tag "$TAG" --mode trusted --receipt "$RUNNER_TEMP/recheck.json" + rustup toolchain install "$TOOLCHAIN" --profile minimal + cargo +"$TOOLCHAIN" package --locked --manifest-path "$MANIFEST" --registry crates-io -p "$CRATE" + python3 scripts/lsp_crates_release.py compare --tag "$TAG" --expected "$RUNNER_TEMP/qualified/qualified-package.json" --receipt "$RUNNER_TEMP/reproduced.json" + - name: Obtain temporary crates.io publish token + id: auth + uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 + - name: Publish and verify actual registry checksum + env: + CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} + CRATE: ${{ needs.qualify.outputs.crate }} + TOOLCHAIN: ${{ needs.qualify.outputs.toolchain }} + MANIFEST: ${{ needs.qualify.outputs.manifest }} + CARGO_TARGET_DIR: ${{ runner.temp }}/lsp-crates-target + TAG: ${{ github.ref_name }} + CARGO_HOME: ${{ runner.temp }}/crates-cargo + run: | + set -euo pipefail + cargo +"$TOOLCHAIN" publish --locked --manifest-path "$MANIFEST" --registry crates-io -p "$CRATE" + python3 scripts/lsp_crates_release.py readback --tag "$TAG" --expected "$RUNNER_TEMP/qualified/qualified-package.json" --receipt "$RUNNER_TEMP/published.json" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: crate-published-${{ github.run_attempt }} + path: ${{ runner.temp }}/published.json + if-no-files-found: error + bootstrap: + needs: qualify + if: github.event_name == 'workflow_dispatch' && inputs.auth_mode == 'bootstrap' + environment: crates-io-bootstrap + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: '3.12' + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: crate-qualified-${{ github.run_attempt }} + path: ${{ runner.temp }}/qualified + - name: Reproduce first-publication package before authentication + env: + CRATE: ${{ needs.qualify.outputs.crate }} + TOOLCHAIN: ${{ needs.qualify.outputs.toolchain }} + MANIFEST: ${{ needs.qualify.outputs.manifest }} + CARGO_TARGET_DIR: ${{ runner.temp }}/lsp-crates-target + TAG: ${{ github.ref_name }} + CARGO_HOME: ${{ runner.temp }}/crates-cargo + run: | + set -euo pipefail + python3 scripts/lsp_crates_release.py validate --tag "$TAG" --mode bootstrap --receipt "$RUNNER_TEMP/recheck.json" + rustup toolchain install "$TOOLCHAIN" --profile minimal + cargo +"$TOOLCHAIN" package --locked --manifest-path "$MANIFEST" --registry crates-io -p "$CRATE" + python3 scripts/lsp_crates_release.py compare --tag "$TAG" --expected "$RUNNER_TEMP/qualified/qualified-package.json" --receipt "$RUNNER_TEMP/reproduced.json" + - name: Bootstrap previously unpublished crate and verify checksum + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + CRATE: ${{ needs.qualify.outputs.crate }} + TOOLCHAIN: ${{ needs.qualify.outputs.toolchain }} + MANIFEST: ${{ needs.qualify.outputs.manifest }} + CARGO_TARGET_DIR: ${{ runner.temp }}/lsp-crates-target + TAG: ${{ github.ref_name }} + CARGO_HOME: ${{ runner.temp }}/crates-cargo + run: | + set -euo pipefail + test -n "$CARGO_REGISTRY_TOKEN" + cargo +"$TOOLCHAIN" publish --locked --manifest-path "$MANIFEST" --registry crates-io -p "$CRATE" + python3 scripts/lsp_crates_release.py readback --tag "$TAG" --expected "$RUNNER_TEMP/qualified/qualified-package.json" --receipt "$RUNNER_TEMP/published.json" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: crate-bootstrapped-${{ github.run_attempt }} + path: ${{ runner.temp }}/published.json + if-no-files-found: error + binaries: + needs: [qualify, publish, bootstrap] + if: >- + always() && needs.qualify.result == 'success' && + needs.qualify.outputs.crate == 'terraphim_lsp' && + (needs.publish.result == 'success' || needs.bootstrap.result == 'success') + permissions: + contents: write + uses: ./.github/workflows/lsp-publish.yml + with: + tag: ${{ github.ref_name }} + source-sha: ${{ github.sha }} + toolchain: ${{ needs.qualify.outputs.toolchain }} diff --git a/.github/workflows/release-comprehensive.yml b/.github/workflows/release-comprehensive.yml index e473550ca..23d044d33 100644 --- a/.github/workflows/release-comprehensive.yml +++ b/.github/workflows/release-comprehensive.yml @@ -93,6 +93,14 @@ jobs: fi fi + # LSP package tags have a single reviewed public-registry publisher. + case "$RELEASE_TAG" in + terraphim_lsp-v*|terraphim_lsp_core-v*) + echo "::error::LSP tags are owned by publish-lsp-crates.yml" + exit 1 + ;; + esac + echo "Resolving release tag: $RELEASE_TAG" OBJECT_JSON="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${RELEASE_TAG}")" OBJECT_SHA="$(jq -r '.object.sha' <<<"$OBJECT_JSON")" @@ -403,15 +411,21 @@ jobs: - name: Build LSP binary if: "!matrix.use_cross" shell: bash + env: + CARGO_TARGET_DIR: ${{ github.workspace }}/target run: | rustup run stable cargo build --release \ + --manifest-path crates/terraphim_lsp/Cargo.toml \ --target ${{ matrix.target }} -p terraphim_lsp --bin terraphim-lsp - name: Build LSP binary (cross / musl) if: matrix.use_cross shell: bash + env: + CARGO_TARGET_DIR: ${{ github.workspace }}/target run: | rustup run stable cross build --release \ + --manifest-path crates/terraphim_lsp/Cargo.toml \ --target ${{ matrix.target }} -p terraphim_lsp --bin terraphim-lsp ls -la "target/${{ matrix.target }}/release/terraphim-lsp" diff --git a/.github/workflows/release-manifest-contract.yml b/.github/workflows/release-manifest-contract.yml index c5a3a01ea..dc8567ae6 100644 --- a/.github/workflows/release-manifest-contract.yml +++ b/.github/workflows/release-manifest-contract.yml @@ -21,6 +21,15 @@ on: - "tests/lsp_handoff_test.py" - "tests/lsp_private_producer_test.py" - "tests/workflow_lsp_handoff_test.py" + - "scripts/lsp_public_binary.py" + - "tests/test_lsp_public_binary.py" + - "scripts/lsp_crates_release.py" + - "scripts/publish-crates.sh" + - ".github/workflows/publish-crates.yml" + - "tests/test_lsp_crates_release.py" + - "release/lsp-crates.json" + - ".github/workflows/publish-lsp-crates.yml" + - ".github/workflows/lsp-public-ci.yml" - "integrations/continue/verify_source.py" - "integrations/continue/integration.json" - "vs-code-terraphim-it/package.json" @@ -67,6 +76,15 @@ on: - "tests/lsp_handoff_test.py" - "tests/lsp_private_producer_test.py" - "tests/workflow_lsp_handoff_test.py" + - "scripts/lsp_public_binary.py" + - "tests/test_lsp_public_binary.py" + - "scripts/lsp_crates_release.py" + - "scripts/publish-crates.sh" + - ".github/workflows/publish-crates.yml" + - "tests/test_lsp_crates_release.py" + - "release/lsp-crates.json" + - ".github/workflows/publish-lsp-crates.yml" + - ".github/workflows/lsp-public-ci.yml" - "integrations/continue/verify_source.py" - "integrations/continue/integration.json" - "vs-code-terraphim-it/package.json" @@ -198,7 +216,7 @@ jobs: - name: actionlint (static workflow analysis) run: | set -euo pipefail - actionlint .github/workflows/lsp-producer.yml .github/workflows/lsp-publish.yml .github/workflows/release-coordinator.yml .github/workflows/release-sign.yml .github/workflows/release-comprehensive.yml .github/workflows/publish-npm.yml .github/workflows/publish-pypi.yml .github/workflows/publish-bun.yml .github/workflows/publish-tauri.yml .github/workflows/release-manifest-contract.yml + actionlint .github/workflows/publish-crates.yml .github/workflows/lsp-public-ci.yml .github/workflows/publish-lsp-crates.yml .github/workflows/lsp-producer.yml .github/workflows/lsp-publish.yml .github/workflows/release-coordinator.yml .github/workflows/release-sign.yml .github/workflows/release-comprehensive.yml .github/workflows/publish-npm.yml .github/workflows/publish-pypi.yml .github/workflows/publish-bun.yml .github/workflows/publish-tauri.yml .github/workflows/release-manifest-contract.yml - name: Parse workflow YAML run: | @@ -217,7 +235,7 @@ jobs: run: python -m unittest -v tests.workflow_release_coordinator_contract_test - name: Private-source LSP handoff and workflow contracts - run: python -m unittest -v tests.lsp_handoff_test tests.lsp_private_producer_test tests.workflow_lsp_handoff_test + run: python -m unittest -v tests.lsp_handoff_test tests.lsp_private_producer_test tests.workflow_lsp_handoff_test tests.test_lsp_public_binary tests.test_lsp_crates_release - name: Run exhaustive release-writer and recovery contract tests run: python -m unittest -v tests.workflow_release_recovery_contract_test diff --git a/Cargo.toml b/Cargo.toml index 2cf2c8685..5278b6c66 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,9 @@ resolver = "2" members = ["crates/*", "terraphim_server", "terraphim_firecracker", "terraphim_ai_nodejs"] # Note: terraphim_usage and terraphim_ccusage are new crates in crates/* exclude = [ + # Public LSP packages use their own bounded crates.io-only workspace. + "crates/terraphim_lsp", + "crates/terraphim_lsp_core", # terraphim-core crates (terraphim_types, terraphim_test_utils, # terraphim-markdown-parser, terraphim_automata, terraphim_rolegraph) were # extracted to the standalone terraphim-core repo and are now consumed from diff --git a/crates/terraphim_lsp/Cargo.lock b/crates/terraphim_lsp/Cargo.lock new file mode 100644 index 000000000..3aa2da46e --- /dev/null +++ b/crates/terraphim_lsp/Cargo.lock @@ -0,0 +1,2291 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "auto_impl" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683bf733a032aec4f8954e5c0ec9d5c2183c341c49d0939ad77acc0a19fa338a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "criterion" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" +dependencies = [ + "alloca", + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "itertools", + "num-traits", + "oorandom", + "page_size", + "plotters", + "rayon", + "regex", + "serde", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fst" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ab85b9b05e3978cc9a9cf8fea7f01b494e1a09ed3037e16ba39edc7a29eb61a" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999beba7b6e8345721bd280141ed958096a2e4abdf74f67ff4ce49b4b54e47a" +dependencies = [ + "hashbrown 0.12.3", +] + +[[package]] +name = "lsp-types" +version = "0.94.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66bfd44a06ae10647fe3f8214762e9369fd4248df1350924b4ef9e770a85ea1" +dependencies = [ + "bitflags 1.3.2", + "serde", + "serde_json", + "serde_repr", + "url", +] + +[[package]] +name = "markdown" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb" +dependencies = [ + "unicode-id", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoize" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e73b7be6eeec913659aed78e8c72208f5e9c6a5fb7e0831a491946fd234104" +dependencies = [ + "lazy_static", + "lru", + "memoize-inner", +] + +[[package]] +name = "memoize-inner" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dd8f89255d8ff313afabed9a3c83ef0993cc056679dfd001f5111a026f876f7" +dependencies = [ + "lazy_static", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "terraphim-markdown-parser" +version = "1.20.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3c110e28d4055002dd82b6fa904e301ab6b752038224ce1b760c2daaa885119" +dependencies = [ + "markdown", + "serde", + "terraphim_types", + "thiserror 1.0.69", + "ulid", +] + +[[package]] +name = "terraphim_alternatives" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb71ca6a6caea35ff4d2758d573afd3e8d891a92fcc94b86a8e6f0d41b0df75a" +dependencies = [ + "serde", + "serde_json", + "terraphim_automata", + "terraphim_types", + "thiserror 2.0.21", + "unicode-segmentation", +] + +[[package]] +name = "terraphim_automata" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dafb5b705cd815e5a5d44b0dc29f54635f059e32a11f2ec65fc3edcd2ba03c62" +dependencies = [ + "ahash 0.8.12", + "aho-corasick", + "bincode", + "fst", + "getrandom 0.3.4", + "log", + "memoize", + "regex", + "serde", + "serde_json", + "sha2", + "strsim", + "terraphim-markdown-parser", + "terraphim_types", + "thiserror 1.0.69", +] + +[[package]] +name = "terraphim_lab" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ddb94387c69e2e546cfba1752a76c99879d276aa7adc992ce9a17444dcb903" +dependencies = [ + "ahash 0.8.12", + "markdown", + "serde", + "serde_json", + "terraphim_alternatives", + "terraphim_automata", + "terraphim_types", + "thiserror 2.0.21", +] + +[[package]] +name = "terraphim_lsp" +version = "1.22.0-rc.4" +dependencies = [ + "criterion", + "env_logger", + "futures", + "log", + "same-file", + "serde", + "serde_json", + "tempfile", + "terraphim_alternatives", + "terraphim_lsp_core", + "terraphim_types", + "tokio", + "tower 0.5.3", + "tower-lsp", +] + +[[package]] +name = "terraphim_lsp_core" +version = "1.23.1" +dependencies = [ + "criterion", + "serde", + "serde_json", + "terraphim_alternatives", + "terraphim_automata", + "terraphim_lab", + "terraphim_types", + "thiserror 1.0.69", +] + +[[package]] +name = "terraphim_types" +version = "1.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7129126b62a2250f76a4a135881beaf87390b652b28db6d4a6c01ecc5bc750c" +dependencies = [ + "ahash 0.8.12", + "anyhow", + "chrono", + "getrandom 0.3.4", + "log", + "schemars", + "serde", + "serde_json", + "strsim", + "thiserror 1.0.69", + "toml", + "ulid", + "uuid", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "tokio" +version = "1.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-lsp" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ba052b54a6627628d9b3c34c176e7eda8359b7da9acd497b9f20998d118508" +dependencies = [ + "async-trait", + "auto_impl", + "bytes", + "dashmap", + "futures", + "httparse", + "lsp-types", + "memchr", + "serde", + "serde_json", + "tokio", + "tokio-util", + "tower 0.4.13", + "tower-lsp-macros", + "tracing", +] + +[[package]] +name = "tower-lsp-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84fd902d4e0b9a4b27f2f440108dc034e1758628a9b702f8ec61ad66355422fa" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ulid" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" +dependencies = [ + "rand", + "serde", + "uuid", + "web-time", +] + +[[package]] +name = "unicode-id" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65dd75ab2d9d379dbe86d4059e91d8b5a6e7054865eaba1ddbfc7a817ff96392" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/terraphim_lsp/Cargo.toml b/crates/terraphim_lsp/Cargo.toml index 0cb33e752..8e89dc0d3 100644 --- a/crates/terraphim_lsp/Cargo.toml +++ b/crates/terraphim_lsp/Cargo.toml @@ -11,20 +11,58 @@ repository = "https://github.com/terraphim/terraphim-ai" keywords = ["ai", "lsp", "language-server", "ide"] license = "Apache-2.0" readme = "README.md" +publish = ["crates-io"] [dependencies] -terraphim_automata = { version = "1.20.2", registry = "terraphim" } -terraphim_types = { version = "1.20.2", registry = "terraphim" } -terraphim_rolegraph = { version = "1.20.2", registry = "terraphim" } +same-file = "1.0.6" +# Pure analysis core (also consumed in-process by terraphim-editor as WASM). +terraphim_lsp_core = { path = "../terraphim_lsp_core", version = "1.23.1" } +terraphim_types = "1.22.3" tower-lsp = "0.20" tokio = { workspace = true } +# The `Service` trait for the exit watcher; the util features are for tests. +tower = { version = "0.5", default-features = false } +serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } env_logger = "0.11" [dev-dependencies] +tempfile = { workspace = true } tower = { version = "0.5", features = ["util"] } +futures = "0.3" +# Reads back the blocks the add-alternative command writes. +terraphim_alternatives = "0.1.1" +criterion = "0.8" + +[[bench]] +name = "unknown_terms" +harness = false [[bin]] name = "terraphim-lsp" path = "src/main.rs" + +# Build and publish this bounded workspace without resolving unrelated packages +# or patches from the repository's root workspace. +[workspace] +resolver = "2" +members = [".", "../terraphim_lsp_core"] + +[workspace.package] +version = "1.22.0-rc.4" +edition = "2024" +rust-version = "1.91" + +[workspace.dependencies] +tokio = { version = "1.0", features = ["full"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +log = "0.4" +tempfile = "3.27" +thiserror = "1.0" + +[profile.release] +lto = false +codegen-units = 1 +opt-level = 3 diff --git a/crates/terraphim_lsp/LICENSE b/crates/terraphim_lsp/LICENSE new file mode 100644 index 000000000..4311d7d04 --- /dev/null +++ b/crates/terraphim_lsp/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2024 Applied Knowledge Systems Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/terraphim_lsp/README.md b/crates/terraphim_lsp/README.md index 4d9dee32c..7d770baaa 100644 --- a/crates/terraphim_lsp/README.md +++ b/crates/terraphim_lsp/README.md @@ -8,11 +8,281 @@ Language Server Protocol (LSP) implementation for Terraphim knowledge graphs. documents. It analyses open documents against a knowledge-graph thesaurus and offers: -- **`textDocument/hover`** - Show concept descriptions when hovering over - thesaurus terms. +- **`textDocument/hover`** - On a thesaurus term, its concept and + description. On a term or a span stored in the annotation block, the + alternatives with their provenance (R-4.4): the stored ones in block order, + labelled `original`, `human`, `ai` or `ai, `, then the KG synonyms + not already stored, labelled `kg`; the active one is marked `active`. A + stored sentence or paragraph span with no KG term shows its stored list. - **`textDocument/completion`** - Suggest knowledge-graph terms at the cursor. -- **`textDocument/diagnostic`** - Warn about terms in the document that are not - present in the thesaurus. +- **Diagnostics**, one model per client so nothing is shown twice: clients + that advertise `textDocument.diagnostic` pull them (`textDocument/diagnostic` + is advertised only to them) and get `workspace/diagnostic/refresh` when + results change without an edit (Lab runs and clears, settings and + thesaurus changes), only if they advertise it (the spec key + `workspace.diagnostics.refreshSupport`, read from the raw `initialize` + because lsp-types drops it; see `handshake.rs`); other clients get + `textDocument/publishDiagnostics` pushes only. Report a malformed trailing `terraphim-alternatives` annotation block + (one diagnostic per document) and fade ghosted text (code `ghosted`, + severity Hint, tag `Unnecessary`; Zed fades it with + `unnecessary_code_fade`). With the opt-in `unknownTerms` setting, also warn + on every occurrence of a word that matches no thesaurus term, each at its + own range. +- **Lab marks** - terraphim-editor's Lab engine (`terraphim_lab`): one + diagnostic code per mark kind (`lab-typo`, `lab-punctuation`, + `lab-weak-sentence`, `lab-long-sentence`, `lab-convoluted-sentence`, + `lab-off-tone`, `lab-hedge`, `lab-filler`), Information for typos and + punctuation, Hint otherwise, message = the engine's reason. Off by default. +- **Trim preview** - the spans a trim level would cut, as `trim-candidate` + Hints tagged `Unnecessary`. +- **`textDocument/codeAction`** - On a KG term, one "Replace with X" action + (`refactor.rewrite`) per other synonym of its concept. The current form is + excluded, the original capitalisation is kept, and a preceding `a`/`an` is + fixed in the same `WorkspaceEdit`. None is offered when its edit (the term + or the article) would change text inside a stored span or ghost, which + would leave that anchor stale; the hover still lists the synonyms. On a + span stored in the annotation block, "Replace with X (provenance)" + (`refactor.terraphim.alternative`, R-2.2) for each inactive stored + alternative: a versioned `WorkspaceEdit` that swaps the body text (with the + `a`/`an` fix-up) and records the new active alternative in the block, + exactly as terraphim-editor does. A malformed block, or one with an anchor + that cannot be placed, offers none. On a Lab typo or punctuation mark, + "Apply fix: X" (`quickfix`) replacing exactly the marked range. On any + open document, command-only trim actions (`refactor.terraphim.trim`, no + `edit`) for clients that cannot add palette commands, such as Zed: + "Trim: Original", "Trim: Slight trim ~10%", "Trim: Tighten more ~20%", + "Trim: Even sharper ~30%" and "Trim: Cut in half ~50%" run + `terraphim.trim.preview` with `{uri, level, version}` ("Original" ends the + preview); "Trim: Make the cuts" runs `terraphim.trim.make_cuts` and "Trim: + Walk through" runs `terraphim.trim.next` from the cursor; with the cursor + inside a faded cut, "Keep: «excerpt»" runs `terraphim.trim.keep` (innermost + cut first). The menu is stateless (Zed caches code actions until the cursor + moves): every level and review action is always listed and reads the trim + state when it runs, so "Make the cuts" is a command, not a cached edit that + would ignore a later Keep. + On a non-empty selection that is not wholly ghosted, "Ghost it"; with the + cursor in a ghost or a selection overlapping one, "Revive" + (`refactor.terraphim.ghost`, R-5.1 and R-5.2). Each carries a versioned + `WorkspaceEdit` that rewrites only the trailing annotation block, creating + it on a plain document, with the editor's own merge (overlapping or + touching ghosts become one), trim and split rules. Spans, alternatives and + overflow are kept, and a malformed block offers neither. The new ghost + renders through the existing `ghosted` hints once the edit is applied. + A kind of its own rather than `refactor.rewrite`, because the body text + never changes. + Also command-only, the Lab actions (`refactor.terraphim.lab`): "Lab: Fix + punctuation and typos", "Lab: Mark the weakest sentences", "Lab: Mark + sentences that run long", "Lab: Mark convoluted sentences", "Lab: Mark words + that don't fit the tone" and "Lab: Mark hedges and filler" run + `terraphim.lab.mark` with `{uri, version, action}`; "Lab: Clear marks" runs + `terraphim.lab.clear`. They are stateless like the trim menu, offered on any + open document while the Lab engine is available, and the marks reach the + client through the usual diagnostic push or refresh. + The overflow (`refactor.terraphim.overflow`, R-6.3 and R-6.4): on a + non-empty selection "Stash in Overflow" moves it out of the body to the end + of the overflow kept in the annotation block, and at the cursor "Pull + back: «excerpt»" copies one overflow paragraph (a run of non-blank lines; + the first five are listed) into the body, as the editor does: the overflow + keeps it, so it can be used again. In the + middle of a line the paragraph goes in as it is; at the start or end of a + line, or on a blank line, it becomes a paragraph of its own, with one blank + line (in the document's line ending) between it and the text on each side. Both are + versioned workspace edits (the body change and the block rewrite, merged + when they touch), through the editor's own span model, so spans after the + edit move with their text. A stash that touches a span, a pull back inside + one, a malformed block and anchors that no longer match offer nothing; + a ghost inside a stashed selection goes with it. The stashed text is the + selection exactly. Overflow is never exported and survives every other + rewrite of the block. + "Export clean Markdown" (`source.terraphim.export`, R-9.3) is a + command-only action running `terraphim.export`; it is offered only when + the export would differ from the document and would not be refused. +- **Trim review** (R-8.4, R-8.5) - after a preview or a Keep the server + shows the status card with `window/showMessageRequest`, without waiting + for it: `Slight trim: 479 → 433 words · −10%. Faded words would go. Keep + one with the Keep action.` with the buttons `Make the cuts`, `Walk + through` and `Done`. "Make the cuts" sends the engine's `make_cuts` edit + (deletions plus join tidy-ups) through `workspace/applyEdit`, versioned and + rechecked against the version the card was shown for; when the client + reports exactly that text back, the preview ends. "Walk through" selects + the next faded span with `window/showDocument` and shows the card again; + "Done" or dismissing it leaves the fades. Hovering a faded span shows the + card numbers and why the span would go. Clients without + `window.showMessage.messageActionItem` get the card as a plain message; + without `workspace.applyEdit`, `terraphim.trim.make_cuts` returns the + edit; without `window.showDocument.support`, `terraphim.trim.next` returns + the range only. +- **`textDocument/inlayHint`** - `[i/n]` after each KG term with + alternatives: the current form is synonym `i` of `n`. Off by default. +- **`textDocument/foldingRange`** (R-9.1) - the trailing annotation block + folds as one `region`, from its opening fence to the closing fence, so the + raw JSON can be collapsed. No range for a document without a block. +- **Re-anchoring on save** (R-9.2) - on `textDocument/didSave` the spans and + ghosts of the annotation block are found again in the body (the editor's + own re-anchoring) and their stored context is refreshed. When anything + moved, one versioned `workspace/applyEdit` rewrites only the block; when + nothing moved, or the block is malformed, nothing is sent, so saving again + is quiet and the edit cannot loop. A span or ghost that can no longer be + placed leaves the block alone and is reported with `window/logMessage` + (rewriting would delete it). The edit leaves the buffer modified: the next + save writes it. Zed does not send `willSaveWaitUntil` (its client + capabilities declare `didSave` only), so `didSave` is the hook; the Lab + refresh that follows waits for the client's change, so Lab marks survive. + Turn it off with `reanchorOnSave`. +- **`workspace/executeCommand`** - see below. + +Edits are versioned (`documentChanges` with the document version) for +clients that accept them, so a stale edit is rejected rather than applied. + +## Lifecycle + +The server exits as soon as it receives `exit`, whether or not the client +keeps stdin open: with code 0 after a `shutdown` request and code 1 without +one (LSP 3.17). End of input also ends it. + +## Thesaurus + +Hover, "Replace with X" actions, `[i/n]` inlay hints and completion need a +thesaurus: a JSON file in the format terraphim's thesaurus builders write +(`{"name": "...", "data": {"term": {"id": 1, "nterm": "concept"}}}`). The +path is taken from the first of these that is set (empty values are +ignored): + +1. the `thesaurus` setting of the latest `workspace/didChangeConfiguration` + (bare or under `terraphim`); +2. the `thesaurus` member of `initializationOptions` (bare or under + `terraphim`); it is kept when a later `didChangeConfiguration` does not + name a thesaurus, so Zed's `initialization_options` and `settings` can be + combined; +3. the `--thesaurus ` (or `--thesaurus=`) command-line flag; +4. the `TERRAPHIM_THESAURUS` environment variable. + +A leading `~/` expands to the home directory; other relative paths are +relative to the server's working directory (editors usually start it at the +workspace root). With none set, the binary starts with an empty thesaurus +and logs a `window/logMessage` warning saying how to configure one. + +The file is read and compiled on the blocking thread pool at `initialize`, +and again whenever `didChangeConfiguration` changes the path; every open +document is then republished and inlay hints are refreshed. Removing the +configured setting returns to the `initializationOptions` path, then the +launch path, then the constructor's thesaurus (for programmatic use). A missing or invalid file is logged and shown once as a +`window/showMessage` Warning; the server keeps running with an empty +thesaurus for that path and does not retry it until the path changes (or +the server restarts). + +Selecting a thesaurus by Terraphim **role** is not supported yet: resolving +a role needs `terraphim_config` and its persistence stack (device settings, +remote or markdown knowledge graphs, async builders), which this server +does not depend on. Build the role's thesaurus with the Terraphim tools and +point `thesaurus` at the JSON file. + +## Settings + +Passed as `initializationOptions` or through +`workspace/didChangeConfiguration`, bare or under a `terraphim` key (when +both appear, the keys are merged and the nested value wins per top-level +key): + +```json +{ + "thesaurus": "/path/to/thesaurus.json", + "inlayHints": false, + "ghostDiagnostics": true, + "unknownTerms": false, + "reanchorOnSave": true, + "lab": { "actions": [], "trigger": "save" } +} +``` + +| Setting | Default | Effect | +|---|---|---| +| `thesaurus` | none | path of the thesaurus JSON file; overrides `--thesaurus` and `TERRAPHIM_THESAURUS`; a value from `initializationOptions` survives later settings without one; reloaded when it changes (see [Thesaurus](#thesaurus)) | +| `inlayHints` | `false` | `[i/n]` inlay hints (the capability is always advertised) | +| `ghostDiagnostics` | `true` | faded hints over ghosted text | +| `unknownTerms` | `false` | a Warning (`Unknown term: X`) on every occurrence of a word that is not part of any thesaurus match; off by default because against a real thesaurus nearly every ordinary word is unknown | +| `reanchorOnSave` | `true` | re-anchor the annotation block against the body on save, with one `workspace/applyEdit` when something moved (see above) | +| `lab.actions` | `[]` | Lab actions run automatically: `typos_and_punctuation`, `weakest_sentences`, `long_sentences`, `convoluted_sentences`, `off_tone`, `hedges_and_filler` | +| `lab.trigger` | `"save"` | `save`: Lab marks and the trim preview are recomputed on open and save; `command`: only by the commands | + +Lab marks are never computed per keystroke: each run parses the whole +Markdown document, and marks flickering while typing would be noise. An edit +drops the current Lab marks and trim hints (their ranges would be stale) +until the next save or command. Lab runs use the blocking thread pool and +hold no document lock; results computed for a version the client has +already replaced are dropped, and a command whose run was overtaken by an +edit fails with `ContentModified`. A per-document generation (bumped by +edits, Lab commands, clears and settings changes) keeps runs that finish +out of order from overwriting newer Lab or trim state; a superseded +command recomputes from the current state. + +On `didChangeConfiguration`, marks of actions that are no longer configured +(and were not requested by command) disappear at once; newly configured +actions are computed immediately with the `save` trigger and on the next +command with `command`. Switching the trigger from `command` to `save` +computes the configured actions at once; switching `save` to `command` +keeps the marks already shown (the next edit drops them as usual). Command-requested actions and trim previews stay +until their `*.clear` command. Ghost hints and unknown-term warnings follow +`ghostDiagnostics` and `unknownTerms`, and inlay hints are refreshed. + +## Commands + +Each takes one JSON object. `version`, when given, must match the server's +version of the document, or the request fails with `ContentModified`. + +| Command | Arguments | Result | +|---|---|---| +| `terraphim.alternative.add` | `uri`, `range`, `text`, `kind?` (`word`/`sentence`/`paragraph`), `version?` | a `WorkspaceEdit` that rewrites only the trailing annotation block, written by terraphim-editor's own writer; the client applies it | +| `terraphim.alternative.add_and_apply` | the same explicit arguments as `alternative.add` | requests `workspace/applyEdit` once and returns `{ applied: true, uri, version }` only after the expected document change is observed | +| `terraphim.lab.mark` | `uri`, `action`, `version?` | `{ action, marks }`; the action sticks to the document until cleared | +| `terraphim.lab.clear` | `uri` | `null` | +| `terraphim.trim.preview` | `uri`, `level` (`original`/`slight`/`tighten`/`sharper`/`half`), `version?` | `{ level, candidates, status, words_before, words_after, percent }`, `status` being the Lab status card (`61 → 55 words · −10%`); the card is then shown | +| `terraphim.trim.clear` | `uri` | `null` | +| `terraphim.trim.keep` | `uri`, `cut`, `version?` | like `terraphim.trim.preview`, with the cut un-faded; the card is shown again | +| `terraphim.trim.make_cuts` | `uri`, `version?` | `{ edits, words_after }`; the edit goes out with `workspace/applyEdit`, or is returned as `edit` | +| `terraphim.trim.next` | `uri`, `position`, `version?` | `{ range }` of the next faded span after `position`, wrapping; `null` with nothing faded | +| `terraphim.export` | `uri`, `version?` | `{ markdown, uri, written }`: the clean Markdown (ghosts cut, no annotation block, no overflow). With `workspace/applyEdit` and `create` resource operations, the client is asked to create `.clean.md` beside the document (replacing an earlier export) and to show it; otherwise only the text is returned. `uri` is `null` for a non-`file:` document | + +`terraphim.export` refuses (`InvalidParams`) a malformed block and a ghost +whose text can no longer be found, rather than export words meant to be cut. + +`terraphim.alternative.add` refuses (`InvalidParams`) a malformed block, a +range outside the body, an alternative equal to the current text or already +present, a range overlapping another span, and a block whose stored anchors +can no longer be placed (rewriting it would drop them). + +For a generic LSP command picker that displays command results without applying +returned edits, use `terraphim.alternative.add_and_apply`. It requires +`workspace.applyEdit` and versioned `documentChanges`; the original +`terraphim.alternative.add` returned-edit contract is unchanged. Supply the +actual open document URI and UTF-16 range explicitly. For a document beginning +with `Risk`, this example adds `Concern` to its saved alternatives: + +```json +{ + "uri": "file:///absolute/path/brief.md", + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 4 } + }, + "text": "Concern", + "kind": "word" +} +``` + +The command does not infer the editor selection or save the file itself. Save +the edited Markdown, then use `terraphim.export_knowledge_graph` to export its +registered synonym graph. A rejected edit, an acknowledgement timeout, an +unobserved edit or a concurrent change is reported as an error; inspect the +document before retrying an uncertain result. A successful command result is +an acknowledgement, not a second edit for the client to apply. + +The analysis lives in [`terraphim_lsp_core`](../terraphim_lsp_core), a pure +crate with no LSP or async dependencies that also builds for +`wasm32-unknown-unknown`; terraphim-editor runs the same code in-process. +It is re-exported as `terraphim_lsp::core`. The trailing annotation block is +never analysed. Positions are converted with UTF-16 columns, so multi-byte +and astral text map to the right characters. ## Installation @@ -20,13 +290,13 @@ Add to your `Cargo.toml`: ```toml [dependencies] -terraphim_lsp = { path = "../terraphim_lsp" } +terraphim_lsp = "1.22.0-rc.4" ``` Or build the standalone binary: ```bash -cargo build -p terraphim_lsp --bin terraphim-lsp +cargo build --manifest-path crates/terraphim_lsp/Cargo.toml -p terraphim_lsp --bin terraphim-lsp ``` ## Usage @@ -37,7 +307,25 @@ The `terraphim-lsp` binary speaks LSP over standard input/output and can be configured in any LSP-compatible editor: ```bash -terraphim-lsp +terraphim-lsp --thesaurus ~/kg/thesaurus.json +# or +TERRAPHIM_THESAURUS=~/kg/thesaurus.json terraphim-lsp +``` + +`terraphim-lsp --help` lists the flags. Unrecognised arguments (such as +`--stdio`) are ignored. In Zed, for example: + +```json +{ + "lsp": { + "terraphim-lsp": { + "initialization_options": { + "thesaurus": "/absolute/path/to/thesaurus.json", + "inlayHints": true + } + } + } +} ``` ### Programmatic use @@ -63,6 +351,12 @@ async fn main() { } ``` +To load the thesaurus from a file the way the binary does, use +`TerraphimLspServer::with_launch_options` (or +`run_stdio_with_launch_options`) with a +`terraphim_lsp::thesaurus::LaunchOptions`; a client `thesaurus` setting +replaces a programmatic thesaurus in either case. + ## Architecture ``` @@ -73,22 +367,37 @@ Editor LSP request │ ├── hover ──────► kg_analysis ──────► Hover response ├── completion ─► completion.rs ────► CompletionItem[] - └── diagnostic ─► diagnostics.rs ───► Diagnostic[] + ├── diagnostic ─► diagnostics.rs + Lab/trim ─► Diagnostic[] + ├── codeAction ─► KgEngine::alternatives_at + Lab fixes ─► CodeAction[] + ├── inlayHint ──► KgEngine::synonym_positions ─► InlayHint[] + └── executeCommand ─► add_alternative / lab_findings / trim_preview + │ + â–¼ + terraphim_lsp_core (KgEngine: CompiledMatcher + ConceptIndex) ``` Open documents are tracked in memory. On every `did_open` and `did_change` the -document is re-analysed and diagnostics are published to the client. +document is re-analysed and diagnostics are published to the client; Lab +results are recomputed on open, save and command only. ## Testing ```bash # Run unit and integration tests -cargo test -p terraphim_lsp +cargo test --manifest-path crates/terraphim_lsp/Cargo.toml -p terraphim_lsp # Run linter -cargo clippy -p terraphim_lsp --all-targets +cargo clippy --manifest-path crates/terraphim_lsp/Cargo.toml -p terraphim_lsp --all-targets ``` ## License Apache-2.0 + +## Public package source + +The LSP and shared analysis core use a bounded workspace rooted at +`crates/terraphim_lsp/Cargo.toml`. Its dependencies come from crates.io; it does +not inherit private registry dependencies or patches from the repository root. +The packaged tests include their own fixtures. See [LICENSE](LICENSE) for the +Apache-2.0 licence notice. diff --git a/crates/terraphim_lsp/benches/unknown_terms.rs b/crates/terraphim_lsp/benches/unknown_terms.rs new file mode 100644 index 000000000..7ffa25ff0 --- /dev/null +++ b/crates/terraphim_lsp/benches/unknown_terms.rs @@ -0,0 +1,29 @@ +//! Unknown-term analysis on a large document against the real fixture +//! thesaurus: the scan is linear in words plus matches (#3437 review). + +use std::hint::black_box; + +use criterion::{Criterion, criterion_group, criterion_main}; +use terraphim_lsp::core::KgEngine; +use terraphim_lsp::kg_analysis::analyse_kg_document; + +const THESAURUS_JSON: &str = include_str!("../tests/fixtures/writing_thesaurus.json"); + +fn large_document(words: usize) -> String { + let sentence = "We made a choice to use the rubber in the coffee shop with an LLM today. "; + let per_sentence = sentence.split_whitespace().count(); + sentence.repeat(words.div_ceil(per_sentence)) +} + +fn bench_unknown_terms(c: &mut Criterion) { + let engine = KgEngine::from_json(THESAURUS_JSON).expect("fixture thesaurus"); + for words in [1_000, 10_000, 50_000] { + let text = large_document(words); + c.bench_function(&format!("analyse_kg_document/{words}_words"), |b| { + b.iter(|| analyse_kg_document(black_box(&text), &engine)); + }); + } +} + +criterion_group!(benches, bench_unknown_terms); +criterion_main!(benches); diff --git a/crates/terraphim_lsp/src/commands.rs b/crates/terraphim_lsp/src/commands.rs new file mode 100644 index 000000000..24720f544 --- /dev/null +++ b/crates/terraphim_lsp/src/commands.rs @@ -0,0 +1,276 @@ +//! `workspace/executeCommand` commands and their arguments. +//! +//! Every command takes a single JSON object as its only argument. Commands +//! that act on a document accept an optional `version`: when given and the +//! server holds a different version of the document, the request fails with +//! `ContentModified` (-32801) instead of acting on text the client no longer +//! shows. +//! +//! | Command | Arguments | Result | +//! |---|---|---| +//! | [`ADD_ALTERNATIVE`] | `uri`, `range`, `text`, `kind?`, `version?` | a `WorkspaceEdit` rewriting the annotation block | +//! | [`ADD_ALTERNATIVE_AND_APPLY`] | same as [`ADD_ALTERNATIVE`] | `{ applied: true, uri, version }` after the client applies and reports the edit | +//! | [`LAB_MARK`] | `uri`, `action`, `version?` | `{ action, marks }`; the marks are published as diagnostics | +//! | [`LAB_CLEAR`] | `uri` | `null`; Lab marks removed | +//! | [`TRIM_PREVIEW`] | `uri`, `level`, `version?` | `{ level, candidates, status, words_before, words_after, percent }`; candidates published as faded hints, then the status card | +//! | [`TRIM_CLEAR`] | `uri` | `null`; trim hints removed | +//! | [`TRIM_KEEP`] | `uri`, `cut`, `version?` | like [`TRIM_PREVIEW`]; the cut is un-faded, then the card is re-sent | +//! | [`TRIM_MAKE_CUTS`] | `uri`, `version?` | `{ edits, words_after }`, plus `edit` (a `WorkspaceEdit`) when the client cannot take `workspace/applyEdit` | +//! | [`EXPORT`] | `uri`, `version?` | `{ markdown, uri?, written }`: the clean Markdown (ghosts cut, no annotation block, no overflow); written to `uri` (`.clean.md`) with `workspace/applyEdit` when the client allows it | +//! | [`TRIM_NEXT`] | `uri`, `position`, `version?` | `{ range }` of the next faded span after `position` (wrapping), selected with `window/showDocument`; `null` when nothing is faded | +//! +//! `action` is one of `typos_and_punctuation`, `weakest_sentences`, +//! `long_sentences`, `convoluted_sentences`, `off_tone`, +//! `hedges_and_filler`; `level` one of `original` (same as clearing), +//! `slight`, `tighten`, `sharper`, `half`. +//! +//! Requested Lab actions and the trim level stick to the document until +//! cleared. Their results are dropped on every edit (stale ranges are worse +//! than none) and recomputed on save when `lab.trigger` is `save` (the +//! default), or on the next command when it is `command`. +//! +//! Lab runs happen on the blocking pool without holding the document lock. +//! If the document changes while `terraphim.lab.mark` or +//! `terraphim.trim.preview` is computing, the results are dropped (never +//! published) and the command fails with `ContentModified`; the requested +//! action or level is kept for the next save or command. If another Lab +//! command (or a clear) changes the requested state of the same version +//! meanwhile, the earlier run is superseded and never installed; the +//! superseded command recomputes from the current state, so every command +//! result matches what is installed. + +use serde::Deserialize; +use serde_json::Value; +use tower_lsp::jsonrpc::{Error, ErrorCode}; +use tower_lsp::lsp_types::{Position, Range, Url}; + +use terraphim_lsp_core::{CutId, LabAction, SpanKind, TrimLevel}; + +/// Add a human-written alternative for a range to the annotation block. +/// +/// The result is a `WorkspaceEdit` (versioned when the client accepts +/// `documentChanges`, like the code actions) that replaces the trailing +/// block and nothing else. The server does not apply it: the client applies +/// the returned edit, so a client that never asked for it is never edited. +pub const ADD_ALTERNATIVE: &str = "terraphim.alternative.add"; + +/// Add an alternative through a generic LSP command picker, applying it once. +/// +/// Requires `workspace.applyEdit` and versioned `documentChanges`. The server +/// requests the annotation edit and waits for the matching document change; +/// the result is an acknowledgement, never another edit to apply. No selection +/// or text is inferred: the caller supplies [`AddAlternativeArgs`]. +pub const ADD_ALTERNATIVE_AND_APPLY: &str = "terraphim.alternative.add_and_apply"; + +/// Run one Lab action on a document and publish its marks as diagnostics +/// (one code per mark kind; typo and punctuation marks get "Apply fix: X" +/// quick fixes). +pub const LAB_MARK: &str = "terraphim.lab.mark"; + +/// Remove a document's Lab marks and forget the actions requested for it. +pub const LAB_CLEAR: &str = "terraphim.lab.clear"; + +/// Publish the spans a trim level would cut as faded (`Unnecessary`) hints. +pub const TRIM_PREVIEW: &str = "terraphim.trim.preview"; + +/// Remove a document's trim preview. +pub const TRIM_CLEAR: &str = "terraphim.trim.clear"; + +/// Keep one faded cut of the trim preview (un-fade it) and recompute the +/// status card. +pub const TRIM_KEEP: &str = "terraphim.trim.keep"; + +/// Delete every still-faded span of the trim preview ("Make the cuts"): +/// the server sends the edit with `workspace/applyEdit`, or returns it to a +/// client that cannot take that request. +pub const TRIM_MAKE_CUTS: &str = "terraphim.trim.make_cuts"; + +/// Select the next faded span after a position ("Walk through"). +pub const TRIM_NEXT: &str = "terraphim.trim.next"; + +/// Export the clean Markdown of a document (R-9.3): ghosts cut, annotation +/// block and overflow omitted. The server never writes to disk itself: it +/// asks the client to create `.clean.md` beside the document with +/// `workspace/applyEdit`, and returns the text either way. +pub const EXPORT: &str = "terraphim.export"; + +/// Export document synonyms to `.knowledge-graph.json` via the client. +/// Returns `{ graph, uri, written }`, preserving the original document and role. +pub const EXPORT_KNOWLEDGE_GRAPH: &str = "terraphim.export_knowledge_graph"; + +/// Every command the server executes, as advertised in +/// `executeCommandProvider.commands`. +pub const ALL: &[&str] = &[ + ADD_ALTERNATIVE, + ADD_ALTERNATIVE_AND_APPLY, + LAB_MARK, + LAB_CLEAR, + TRIM_PREVIEW, + TRIM_CLEAR, + TRIM_KEEP, + TRIM_MAKE_CUTS, + TRIM_NEXT, + EXPORT, + EXPORT_KNOWLEDGE_GRAPH, +]; + +/// Arguments of [`ADD_ALTERNATIVE`]. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AddAlternativeArgs { + /// The document. + pub uri: Url, + /// The body range the alternative is for. + pub range: Range, + /// The alternative text. + pub text: String, + /// Span granularity (`word`, `sentence` or `paragraph`); inferred from + /// the range's text when omitted. + #[serde(default)] + pub kind: Option, + /// The document version the range refers to. + #[serde(default)] + pub version: Option, +} + +/// Arguments of [`LAB_MARK`]. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LabMarkArgs { + /// The document. + pub uri: Url, + /// The action to run. + pub action: LabAction, + /// The document version the client expects. + #[serde(default)] + pub version: Option, +} + +/// Arguments of [`TRIM_PREVIEW`]. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TrimPreviewArgs { + /// The document. + pub uri: Url, + /// The level to preview. + pub level: TrimLevel, + /// The document version the client expects. + #[serde(default)] + pub version: Option, +} + +/// Arguments of [`TRIM_KEEP`]. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TrimKeepArgs { + /// The document. + pub uri: Url, + /// The cut to keep, as offered by the "Keep" code action. + pub cut: CutId, + /// The document version the client expects. + #[serde(default)] + pub version: Option, +} + +/// Arguments of [`TRIM_MAKE_CUTS`]. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TrimMakeCutsArgs { + /// The document. + pub uri: Url, + /// The document version the client expects. + #[serde(default)] + pub version: Option, +} + +/// Arguments of [`TRIM_NEXT`]. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TrimNextArgs { + /// The document. + pub uri: Url, + /// Where to start looking; the next span starts after it. + pub position: Position, + /// The document version the client expects. + #[serde(default)] + pub version: Option, +} + +/// Arguments of [`EXPORT`]. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExportArgs { + /// The document. + pub uri: Url, + /// The document version the client expects. + #[serde(default)] + pub version: Option, +} + +/// Arguments of [`LAB_CLEAR`] and [`TRIM_CLEAR`]. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DocumentArgs { + /// The document. + pub uri: Url, +} + +/// Parse a command's single object argument. +pub fn single_argument( + command: &str, + arguments: Vec, +) -> Result { + let [argument]: [Value; 1] = arguments + .try_into() + .map_err(|_| invalid_params(format!("{command} takes exactly one object argument")))?; + serde_json::from_value(argument) + .map_err(|error| invalid_params(format!("{command}: invalid arguments: {error}"))) +} + +/// An `InvalidParams` error with `message`. +pub fn invalid_params(message: impl Into) -> Error { + Error { + code: ErrorCode::InvalidParams, + message: message.into().into(), + data: None, + } +} + +/// A `ContentModified` error: the client's version is not the server's. +pub fn content_modified(expected: i32, held: i32) -> Error { + Error { + code: ErrorCode::ContentModified, + message: format!("document is at version {held}, not {expected}").into(), + data: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parses_one_object_argument() { + let args: AddAlternativeArgs = single_argument( + ADD_ALTERNATIVE, + vec![json!({ + "uri": "file:///a.md", + "range": {"start": {"line": 0, "character": 1}, "end": {"line": 0, "character": 4}}, + "text": "x", + "kind": "sentence" + })], + ) + .unwrap(); + assert_eq!(args.kind, Some(SpanKind::Sentence)); + assert_eq!(args.version, None); + } + + #[test] + fn rejects_wrong_arity_and_shapes() { + let none = single_argument::(ADD_ALTERNATIVE, vec![]); + assert_eq!(none.unwrap_err().code, ErrorCode::InvalidParams); + let bad = single_argument::(ADD_ALTERNATIVE, vec![json!({"uri": 3})]); + assert_eq!(bad.unwrap_err().code, ErrorCode::InvalidParams); + } +} diff --git a/crates/terraphim_lsp/src/completion.rs b/crates/terraphim_lsp/src/completion.rs index 8f71754f4..b9f82fe25 100644 --- a/crates/terraphim_lsp/src/completion.rs +++ b/crates/terraphim_lsp/src/completion.rs @@ -3,8 +3,11 @@ //! Builds LSP completion items from a knowledge-graph thesaurus and the //! current document context. +use terraphim_lsp_core::LineIndex; use tower_lsp::lsp_types::{CompletionItem, CompletionItemKind, Position}; +use crate::convert; + use terraphim_types::Thesaurus; /// Build completion items for the given thesaurus. @@ -39,12 +42,17 @@ pub fn build_completions(thesaurus: &Thesaurus, word: &str) -> Vec String { - let lines: Vec<&str> = text.lines().collect(); - let line = lines.get(position.line as usize).unwrap_or(&""); - let col = position.character as usize; - let before = &line[..col.min(line.len())]; + let index = LineIndex::new(text); + let cursor = convert::byte_offset(&index, position); + if convert::position(&index, cursor).line != position.line { + // The position is past the last line. + return String::new(); + } + let line_start = text[..cursor].rfind('\n').map_or(0, |newline| newline + 1); + let before = &text[line_start..cursor]; before .split(|c: char| !(c.is_alphanumeric() || c == '-' || c == '_')) @@ -108,4 +116,31 @@ mod tests { }; assert_eq!(word_at_position(text, pos), "tok"); } + + #[test] + fn test_word_at_position_counts_utf16_and_never_panics() { + // '😀' is two UTF-16 units; character 4 is after "😀 r". + let text = "😀 rust\ncafé tok"; + let pos = Position { + line: 0, + character: 4, + }; + assert_eq!(word_at_position(text, pos), "r"); + let pos = Position { + line: 1, + character: 8, + }; + assert_eq!(word_at_position(text, pos), "tok"); + // Inside the surrogate pair and past the last line. + let pos = Position { + line: 0, + character: 1, + }; + assert_eq!(word_at_position(text, pos), ""); + let pos = Position { + line: 9, + character: 0, + }; + assert_eq!(word_at_position(text, pos), ""); + } } diff --git a/crates/terraphim_lsp/src/convert.rs b/crates/terraphim_lsp/src/convert.rs new file mode 100644 index 000000000..487850e2e --- /dev/null +++ b/crates/terraphim_lsp/src/convert.rs @@ -0,0 +1,142 @@ +//! Conversions between `terraphim_lsp_core` plain data and `lsp_types`. +//! +//! LSP positions count UTF-16 code units (the protocol's default encoding); +//! the core's [`LineIndex`] does the counting, so multi-byte and astral text +//! map to the right columns. + +use terraphim_lsp_core::{ + Diagnostic as CoreDiagnostic, DiagnosticTag as CoreTag, LineIndex, LinePosition, Severity, + TextEdit as CoreTextEdit, TextRange, +}; +use tower_lsp::lsp_types::{ + Diagnostic, DiagnosticSeverity, DiagnosticTag, NumberOrString, Position, Range, TextEdit, +}; + +/// Source name attached to every diagnostic this server publishes. +pub(crate) const SOURCE: &str = "terraphim-lsp"; + +/// The byte offset of an LSP position. +pub(crate) fn byte_offset(index: &LineIndex<'_>, position: Position) -> usize { + index.byte_offset(LinePosition { + line: position.line, + character: position.character, + }) +} + +/// The LSP position of a byte offset. +pub(crate) fn position(index: &LineIndex<'_>, byte: usize) -> Position { + let LinePosition { line, character } = index.position(byte); + Position { line, character } +} + +/// The LSP range of the byte range `start..end`. +pub(crate) fn byte_range(index: &LineIndex<'_>, start: usize, end: usize) -> Range { + Range { + start: position(index, start), + end: position(index, end), + } +} + +/// The LSP range of a core [`TextRange`]. +pub(crate) fn range(index: &LineIndex<'_>, range: TextRange) -> Range { + byte_range(index, range.start.byte, range.end.byte) +} + +/// An LSP text edit from a core edit. +pub(crate) fn text_edit(index: &LineIndex<'_>, edit: &CoreTextEdit) -> TextEdit { + TextEdit { + range: range(index, edit.range), + new_text: edit.new_text.clone(), + } +} + +/// An LSP diagnostic from a core diagnostic. +pub(crate) fn diagnostic(index: &LineIndex<'_>, diagnostic: &CoreDiagnostic) -> Diagnostic { + Diagnostic { + range: range(index, diagnostic.range), + severity: Some(match diagnostic.severity { + Severity::Error => DiagnosticSeverity::ERROR, + Severity::Warning => DiagnosticSeverity::WARNING, + Severity::Information => DiagnosticSeverity::INFORMATION, + Severity::Hint => DiagnosticSeverity::HINT, + }), + code: Some(NumberOrString::String(diagnostic.code.as_str().to_string())), + code_description: None, + source: Some(SOURCE.to_string()), + message: diagnostic.message.clone(), + related_information: None, + tags: (!diagnostic.tags.is_empty()).then(|| { + diagnostic + .tags + .iter() + .map(|tag| match tag { + CoreTag::Unnecessary => DiagnosticTag::UNNECESSARY, + }) + .collect() + }), + data: None, + } +} + +/// The byte range of an LSP range. +pub(crate) fn byte_range_of(index: &LineIndex<'_>, range: Range) -> std::ops::Range { + byte_offset(index, range.start)..byte_offset(index, range.end) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn positions_count_utf16_units() { + // '😀' is 4 bytes but 2 UTF-16 units. + let text = "😀 rust\nnext"; + let index = LineIndex::new(text); + assert_eq!( + position(&index, 5), + Position { + line: 0, + character: 3 + } + ); + assert_eq!( + byte_offset( + &index, + Position { + line: 0, + character: 3 + } + ), + 5 + ); + assert_eq!( + byte_range(&index, 5, 14), + Range { + start: Position { + line: 0, + character: 3 + }, + end: Position { + line: 1, + character: 4 + }, + } + ); + } + + #[test] + fn out_of_range_positions_clamp() { + let text = "abc\ndef"; + let index = LineIndex::new(text); + let past_line_end = Position { + line: 0, + character: 100, + }; + assert_eq!(byte_offset(&index, past_line_end), 3); + let past_last_line = Position { + line: 5, + character: 0, + }; + assert_eq!(byte_offset(&index, past_last_line), text.len()); + } +} diff --git a/crates/terraphim_lsp/src/diagnostics.rs b/crates/terraphim_lsp/src/diagnostics.rs index 9a87b7700..e503ca69c 100644 --- a/crates/terraphim_lsp/src/diagnostics.rs +++ b/crates/terraphim_lsp/src/diagnostics.rs @@ -1,115 +1,136 @@ //! Diagnostic helpers for Terraphim LSP. //! -//! Converts [`KgAnalysis`] results into LSP diagnostics, surfacing unknown -//! terms as warnings. +//! Converts [`KgAnalysis`] results into LSP diagnostics: the core's own +//! diagnostics (a malformed annotation block), ghost hints and, when the +//! `unknownTerms` setting is on, one warning per unknown-word occurrence. -use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, Position, Range}; +use terraphim_lsp_core::LineIndex; +use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, Range}; +use crate::convert; use crate::kg_analysis::KgAnalysis; -/// Build LSP diagnostics from a KG analysis result. +/// Build LSP diagnostics for the unknown terms of an analysis without the +/// document text. /// -/// Unknown terms are reported as warnings at their first occurrence in the -/// document. Matched terms do not produce diagnostics. +/// Every diagnostic is placed at the start of the document, because ranges +/// cannot be converted to LSP positions without the text. Prefer +/// [`unknown_term_diagnostics`], which reports each occurrence at its own +/// range. pub fn build_diagnostics(analysis: &KgAnalysis) -> Vec { analysis .unknown_terms .iter() - .map(|term| Diagnostic { - range: Range { - start: Position { - line: 0, - character: 0, - }, - end: Position { - line: 0, - character: 0, - }, - }, - severity: Some(DiagnosticSeverity::WARNING), - code: None, - code_description: None, - source: Some("terraphim-lsp".to_string()), - message: format!("Unknown term: {}", term), - related_information: None, - tags: None, - data: None, - }) + .map(|term| unknown_term_diagnostic(&term.text, Range::default())) .collect() } -/// Build diagnostics with byte-offset ranges mapped to LSP positions. +/// Unknown terms and core diagnostics, with ranges mapped to LSP positions. /// -/// This richer variant locates each unknown term in the document text and -/// reports the diagnostic at the term's actual range. Unknown terms whose -/// positions cannot be located fall back to the start of the document. +/// The concatenation of [`unknown_term_diagnostics`] and +/// [`core_diagnostics`]; ghost hints are not included (see +/// [`ghost_diagnostics`]). The server itself only adds unknown terms when +/// the `unknownTerms` setting is on. pub fn build_diagnostics_with_positions(analysis: &KgAnalysis, text: &str) -> Vec { + let mut diagnostics = unknown_term_diagnostics(analysis, text); + diagnostics.extend(core_diagnostics(analysis, text)); + diagnostics +} + +/// One warning per unknown-word occurrence, each at its own range, with +/// UTF-16 columns from the core's [`LineIndex`]. +pub fn unknown_term_diagnostics(analysis: &KgAnalysis, text: &str) -> Vec { + let index = LineIndex::new(text); analysis .unknown_terms .iter() - .filter_map(|term| { - let range = find_term_range(text, term)?; - Some(Diagnostic { - range, - severity: Some(DiagnosticSeverity::WARNING), - code: None, - code_description: None, - source: Some("terraphim-lsp".to_string()), - message: format!("Unknown term: {}", term), - related_information: None, - tags: None, - data: None, - }) + .map(|term| { + let range = convert::byte_range(&index, term.bytes.start, term.bytes.end); + unknown_term_diagnostic(&term.text, range) }) .collect() } -/// Locate the first occurrence of a term in the document and return its LSP -/// range. Returns `None` if the term cannot be found. -fn find_term_range(text: &str, term: &str) -> Option { - let lower = term.to_lowercase(); - let text_lower = text.to_lowercase(); - let byte_start = text_lower.find(&lower)?; - let byte_end = byte_start + term.len(); - - Some(Range { - start: byte_offset_to_position(text, byte_start), - end: byte_offset_to_position(text, byte_end), - }) +/// The core's diagnostics (such as a malformed annotation block) as LSP +/// diagnostics. Always published. +pub fn core_diagnostics(analysis: &KgAnalysis, text: &str) -> Vec { + let index = LineIndex::new(text); + analysis + .diagnostics + .iter() + .map(|diagnostic| convert::diagnostic(&index, diagnostic)) + .collect() } -/// Convert a byte offset in a UTF-8 document to an LSP position. -fn byte_offset_to_position(text: &str, byte_offset: usize) -> Position { - let mut line = 0u32; - let mut character = 0u32; +/// The analysis' ghost hints as LSP diagnostics: severity `Hint`, tagged +/// `Unnecessary` so clients fade the ghosted text. +pub fn ghost_diagnostics(analysis: &KgAnalysis, text: &str) -> Vec { + let index = LineIndex::new(text); + analysis + .ghosts + .iter() + .map(|diagnostic| convert::diagnostic(&index, diagnostic)) + .collect() +} - for (idx, ch) in text.char_indices() { - if idx >= byte_offset { - break; - } - if ch == '\n' { - line += 1; - character = 0; - } else { - character += ch.len_utf16() as u32; - } +fn unknown_term_diagnostic(term: &str, range: Range) -> Diagnostic { + Diagnostic { + range, + severity: Some(DiagnosticSeverity::WARNING), + code: None, + code_description: None, + source: Some(convert::SOURCE.to_string()), + message: format!("Unknown term: {}", term), + related_information: None, + tags: None, + data: None, } - - Position { line, character } } #[cfg(test)] mod tests { use super::*; - use crate::kg_analysis::KgAnalysis; + use terraphim_lsp_core::KgEngine; + use tower_lsp::lsp_types::Position; + + use crate::kg_analysis::UnknownTerm; + + /// An analysis whose unknown terms are every occurrence of `terms` in + /// `text`, as the analysis itself records them. + fn unknown(text: &str, terms: &[&str]) -> KgAnalysis { + let lower = text.to_lowercase(); + let mut unknown_terms: Vec = terms + .iter() + .flat_map(|term| { + lower.match_indices(*term).map(|(start, _)| UnknownTerm { + text: text[start..start + term.len()].to_string(), + bytes: start..start + term.len(), + }) + }) + .collect(); + unknown_terms.sort_by_key(|term| term.bytes.start); + KgAnalysis { + unknown_terms, + ..KgAnalysis::empty() + } + } + + fn starts(diagnostics: &[Diagnostic]) -> Vec<(u32, u32, u32)> { + diagnostics + .iter() + .map(|d| { + ( + d.range.start.line, + d.range.start.character, + d.range.end.character, + ) + }) + .collect() + } #[test] fn test_build_diagnostics_reports_unknown_terms() { - let analysis = KgAnalysis { - matched_terms: vec![], - unknown_terms: vec!["xyz".to_string()], - }; - let diagnostics = build_diagnostics(&analysis); + let diagnostics = build_diagnostics(&unknown("xyz", &["xyz"])); assert_eq!(diagnostics.len(), 1); assert_eq!(diagnostics[0].message, "Unknown term: xyz"); assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::WARNING)); @@ -117,11 +138,8 @@ mod tests { #[test] fn test_build_diagnostics_with_positions() { - let analysis = KgAnalysis { - matched_terms: vec![], - unknown_terms: vec!["xyz".to_string()], - }; - let diagnostics = build_diagnostics_with_positions(&analysis, "rust and xyz"); + let diagnostics = + build_diagnostics_with_positions(&unknown("rust and xyz", &["xyz"]), "rust and xyz"); assert_eq!(diagnostics.len(), 1); assert_eq!(diagnostics[0].range.start.line, 0); assert_eq!(diagnostics[0].range.start.character, 9); @@ -129,10 +147,45 @@ mod tests { } #[test] - fn test_byte_offset_to_position_multiline() { - let text = "line one\nline two"; - let pos = byte_offset_to_position(text, 14); // 't' in "two" - assert_eq!(pos.line, 1); - assert_eq!(pos.character, 5); + fn test_positions_count_utf16_units() { + let text = "😀 é\nab XYZ"; + let diagnostics = build_diagnostics_with_positions(&unknown(text, &["xyz"]), text); + assert_eq!(diagnostics[0].range.start.line, 1); + assert_eq!(diagnostics[0].range.start.character, 3); + assert_eq!(diagnostics[0].range.end.character, 6); + assert_eq!(diagnostics[0].message, "Unknown term: XYZ"); + } + + #[test] + fn test_repeated_terms_are_reported_at_each_occurrence() { + let text = "the cat\r\nsat on the 😀 mat, the end"; + let diagnostics = unknown_term_diagnostics(&unknown(text, &["the"]), text); + assert_eq!(starts(&diagnostics), [(0, 0, 3), (1, 7, 10), (1, 19, 22)]); + } + + #[test] + fn test_malformed_block_becomes_one_lsp_diagnostic() { + let text = "body\n\n```terraphim-alternatives\n{\n"; + let analysis = KgAnalysis { + diagnostics: KgEngine::empty().analyse(text).diagnostics, + ..KgAnalysis::empty() + }; + let diagnostics = build_diagnostics_with_positions(&analysis, text); + assert_eq!(diagnostics.len(), 1); + let block = &diagnostics[0]; + assert_eq!( + block.range.start, + Position { + line: 2, + character: 0 + } + ); + assert_eq!( + block.code, + Some(tower_lsp::lsp_types::NumberOrString::String( + "annotation-block-truncated".to_string() + )) + ); + assert_eq!(block.source.as_deref(), Some("terraphim-lsp")); } } diff --git a/crates/terraphim_lsp/src/handshake.rs b/crates/terraphim_lsp/src/handshake.rs new file mode 100644 index 000000000..2bbede4a5 --- /dev/null +++ b/crates/terraphim_lsp/src/handshake.rs @@ -0,0 +1,250 @@ +//! Reading the client's `initialize` request ahead of tower-lsp. +//! +//! lsp-types 0.94 (and 0.95) model the workspace diagnostics capability as +//! `workspace.diagnostic`, but LSP 3.17 and the clients that follow it (Zed) +//! send `workspace.diagnostics`, which the typed params silently drop. Without +//! this module a server could not tell whether a pulling client accepts +//! `workspace/diagnostic/refresh`. +//! +//! [`peek_initialize`] reads the first LSP frame from the transport, extracts +//! the hint from the raw JSON and returns every byte it consumed, so the +//! caller can replay them in front of the rest of the stream and tower-lsp +//! sees the unmodified conversation. The spec requires `initialize` to be the +//! first request; any other first frame passes through with no hint. + +use serde_json::Value; +use tokio::io::{AsyncRead, AsyncReadExt}; + +/// The longest header block read before giving up and passing the stream +/// through untouched. +const MAX_HEADER_BYTES: usize = 8 * 1024; + +/// The longest `initialize` body read; a larger first frame is passed +/// through unparsed. +const MAX_BODY_BYTES: usize = 16 * 1024 * 1024; + +/// What the first frame said about `workspace/diagnostic/refresh`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct InitializeHint { + /// `workspace.diagnostics.refreshSupport` (the spec key), else the legacy + /// singular `workspace.diagnostic.refreshSupport`; the spec key wins when + /// both are present. `None` when neither is a boolean, or the frame is + /// malformed or not `initialize`. + pub diagnostic_refresh: Option, +} + +/// Read the first frame from `reader` and return its hint together with +/// every byte consumed (header, body and, on a short read, whatever +/// arrived), to be replayed in front of `reader`. +pub async fn peek_initialize(reader: &mut R) -> (InitializeHint, Vec) { + let mut buffered = Vec::new(); + let hint = read_first_frame(reader, &mut buffered) + .await + .map(|body| hint_from_body(&body)) + .unwrap_or_default(); + (hint, buffered) +} + +async fn read_first_frame( + reader: &mut R, + buffered: &mut Vec, +) -> Option> { + // Header block: read byte by byte up to the blank line, so nothing past + // the frame is consumed. + while !buffered.ends_with(b"\r\n\r\n") { + if buffered.len() >= MAX_HEADER_BYTES { + return None; + } + buffered.push(reader.read_u8().await.ok()?); + } + let length = content_length(&buffered[..])?; + if length > MAX_BODY_BYTES { + return None; + } + let start = buffered.len(); + buffered.resize(start + length, 0); + let mut filled = 0; + while filled < length { + match reader.read(&mut buffered[start + filled..]).await { + Ok(0) | Err(_) => { + buffered.truncate(start + filled); + return None; + } + Ok(read) => filled += read, + } + } + Some(buffered[start..].to_vec()) +} + +fn content_length(header: &[u8]) -> Option { + std::str::from_utf8(header).ok()?.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + name.trim() + .eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse().ok())? + }) +} + +fn hint_from_body(body: &[u8]) -> InitializeHint { + let Ok(message) = serde_json::from_slice::(body) else { + return InitializeHint::default(); + }; + if message.get("method").and_then(Value::as_str) != Some("initialize") { + return InitializeHint::default(); + } + let workspace = &message["params"]["capabilities"]["workspace"]; + let flag = |key: &str| workspace[key]["refreshSupport"].as_bool(); + InitializeHint { + diagnostic_refresh: flag("diagnostics").or_else(|| flag("diagnostic")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn frame(body: &str) -> Vec { + format!("Content-Length: {}\r\n\r\n{body}", body.len()).into_bytes() + } + + fn initialize(workspace: &str) -> String { + format!( + r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"capabilities":{{"workspace":{workspace}}}}}}}"# + ) + } + + /// Peek `input` followed by `tail`; the replay must be the input as is. + async fn peek(input: &[u8], tail: &[u8]) -> InitializeHint { + let mut stream = [input, tail].concat(); + let mut reader = &stream[..]; + let (hint, buffered) = peek_initialize(&mut reader).await; + assert_eq!( + [buffered.as_slice(), reader].concat(), + stream, + "replaying the buffer then the rest restores the stream" + ); + stream.clear(); + hint + } + + #[tokio::test] + async fn zed_shaped_plural_key_is_detected() { + let body = initialize(r#"{"diagnostics":{"refreshSupport":true}}"#); + assert_eq!( + peek(&frame(&body), b"next").await.diagnostic_refresh, + Some(true) + ); + } + + #[tokio::test] + async fn legacy_singular_key_is_detected() { + let body = initialize(r#"{"diagnostic":{"refreshSupport":true}}"#); + assert_eq!( + peek(&frame(&body), b"").await.diagnostic_refresh, + Some(true) + ); + } + + #[tokio::test] + async fn explicit_false_is_kept_and_the_spec_key_wins() { + for (workspace, expected) in [ + (r#"{"diagnostics":{"refreshSupport":false}}"#, Some(false)), + ( + r#"{"diagnostics":{"refreshSupport":false},"diagnostic":{"refreshSupport":true}}"#, + Some(false), + ), + ( + r#"{"diagnostics":{"refreshSupport":true},"diagnostic":{"refreshSupport":false}}"#, + Some(true), + ), + ( + r#"{"diagnostics":{},"diagnostic":{"refreshSupport":true}}"#, + Some(true), + ), + ] { + let body = initialize(workspace); + assert_eq!( + peek(&frame(&body), b"").await.diagnostic_refresh, + expected, + "{workspace}" + ); + } + } + + #[tokio::test] + async fn absent_or_malformed_gives_no_hint() { + for workspace in [ + "{}", + r#"{"diagnostics":{}}"#, + r#"{"diagnostics":{"refreshSupport":"yes"}}"#, + ] { + let body = initialize(workspace); + assert_eq!( + peek(&frame(&body), b"").await.diagnostic_refresh, + None, + "{workspace}" + ); + } + let no_workspace = + r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{}}}"#; + assert_eq!( + peek(&frame(no_workspace), b"").await.diagnostic_refresh, + None + ); + } + + #[tokio::test] + async fn a_non_initialize_first_frame_passes_through_without_a_hint() { + let body = r#"{"jsonrpc":"2.0","method":"workspace/diagnostics","params":{"capabilities":{"workspace":{"diagnostics":{"refreshSupport":true}}}}}"#; + assert_eq!(peek(&frame(body), b"tail").await.diagnostic_refresh, None); + assert_eq!(peek(&frame("not json"), b"").await.diagnostic_refresh, None); + } + + #[tokio::test] + async fn header_only_and_partial_reads_are_replayed() { + let body = initialize(r#"{"diagnostics":{"refreshSupport":true}}"#); + let whole = frame(&body); + let header_end = whole.len() - body.len(); + for cut in [ + 0, + 5, + header_end - 1, + header_end, + header_end + 10, + whole.len() - 1, + ] { + assert_eq!( + peek(&whole[..cut], b"").await.diagnostic_refresh, + None, + "cut at {cut}" + ); + } + } + + #[tokio::test] + async fn malformed_headers_pass_through() { + for input in [ + &b"Content-Length: nope\r\n\r\n{}"[..], + b"X-Other: 1\r\n\r\n{}", + ] { + assert_eq!(peek(input, b"").await.diagnostic_refresh, None); + } + let huge = format!("Content-Length: {}\r\n\r\n", MAX_BODY_BYTES + 1); + assert_eq!(peek(huge.as_bytes(), b"xyz").await.diagnostic_refresh, None); + let endless = vec![b'a'; MAX_HEADER_BYTES + 100]; + assert_eq!(peek(&endless, b"").await.diagnostic_refresh, None); + } + + #[tokio::test] + async fn content_type_header_and_case_are_tolerated() { + let body = initialize(r#"{"diagnostics":{"refreshSupport":true}}"#); + let input = format!( + "content-type: application/vscode-jsonrpc; charset=utf-8\r\ncontent-length: {}\r\n\r\n{body}", + body.len() + ); + assert_eq!( + peek(input.as_bytes(), b"").await.diagnostic_refresh, + Some(true) + ); + } +} diff --git a/crates/terraphim_lsp/src/kg_analysis.rs b/crates/terraphim_lsp/src/kg_analysis.rs index b0a50203b..1fc6a3689 100644 --- a/crates/terraphim_lsp/src/kg_analysis.rs +++ b/crates/terraphim_lsp/src/kg_analysis.rs @@ -1,31 +1,39 @@ //! Knowledge-graph analysis helpers for Terraphim LSP. //! -//! Analyses a markdown document against a thesaurus, returning matched -//! KG terms with byte positions and a list of unrecognised words. +//! Term matching, concept ids and the annotation-block boundary come from +//! [`terraphim_lsp_core`]; this module adds the server-only list of +//! unrecognised words used for the opt-in unknown-term diagnostics. -use std::collections::HashSet; +use std::ops::Range; -use terraphim_automata::{Matched, find_matches}; -use terraphim_types::Thesaurus; +use terraphim_lsp_core::{Diagnostic, KgEngine}; -/// Result of analysing a document against a knowledge graph thesaurus. +pub use terraphim_lsp_core::TermMatch; + +/// Result of analysing a document against a knowledge graph. #[derive(Debug, Clone, PartialEq)] pub struct KgAnalysis { - /// Terms from the thesaurus found in the document. + /// Terms from the thesaurus found in the document body, with concept id, + /// normalised concept name and byte plus UTF-16 ranges. pub matched_terms: Vec, - /// Words in the document that did not match any thesaurus entry. - pub unknown_terms: Vec, + /// Every occurrence of a word in the body that is not part of any + /// thesaurus match, in document order, each with its own byte range. + pub unknown_terms: Vec, + /// Problems found by the core, such as a malformed annotation block. + pub diagnostics: Vec, + /// One faded (`Unnecessary`) hint per ghosted span of the annotation + /// block. Published only when the `ghostDiagnostics` setting is on. + pub ghosts: Vec, } -/// A single knowledge-graph term match inside a document. -#[derive(Debug, Clone, PartialEq)] -pub struct TermMatch { - /// The matched term text. - pub term: String, - /// Byte offset range `(start, end)` inside the document. - pub range: (usize, usize), - /// Optional definition/description from the thesaurus. - pub description: Option, +/// One occurrence of a word that is not part of any knowledge-graph match. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnknownTerm { + /// The word as written, without surrounding punctuation. + pub text: String, + /// Byte range of this occurrence in the analysed document. Convert it + /// with [`terraphim_lsp_core::LineIndex`] (UTF-16 columns) for LSP. + pub bytes: Range, } impl KgAnalysis { @@ -34,123 +42,181 @@ impl KgAnalysis { Self { matched_terms: Vec::new(), unknown_terms: Vec::new(), + diagnostics: Vec::new(), + ghosts: Vec::new(), } } - /// Returns true if no terms were matched and no unknown terms were found. + /// Returns true if nothing was matched, no unknown terms were found and + /// there are no diagnostics. pub fn is_empty(&self) -> bool { - self.matched_terms.is_empty() && self.unknown_terms.is_empty() + self.matched_terms.is_empty() + && self.unknown_terms.is_empty() + && self.diagnostics.is_empty() + && self.ghosts.is_empty() } } -/// Analyse a markdown document against a knowledge graph thesaurus. +/// Analyse a markdown document with a knowledge-graph engine. /// -/// Returns matched terms with byte positions and a list of unknown words. -/// The current implementation matches whole thesaurus entries using -/// `terraphim_automata::find_matches`. Unknown terms are extracted as the -/// set of whitespace-separated words that were not part of any match. -pub fn analyse_kg_document(text: &str, thesaurus: &Thesaurus) -> KgAnalysis { - if text.trim().is_empty() || thesaurus.is_empty() { +/// Matching is delegated to [`KgEngine::analyse`], which excludes a trailing +/// `terraphim-alternatives` annotation block. Unknown terms are the +/// whitespace-separated words of the body that were not part of any match. +pub fn analyse_kg_document(text: &str, engine: &KgEngine) -> KgAnalysis { + if text.trim().is_empty() { return KgAnalysis::empty(); } - let matches = match find_matches(text, thesaurus.clone(), true) { - Ok(matches) => matches, - Err(err) => { - log::warn!("KG analysis find_matches failed: {}", err); - return KgAnalysis::empty(); - } - }; - - let mut matched_terms: Vec = Vec::with_capacity(matches.len()); - let mut matched_words: HashSet = HashSet::new(); - - for Matched { - term, - normalized_term, - pos, - } in matches - { - if let Some((start, end)) = pos { - // Record each word of the matched term as known. - for word in term.split_whitespace() { - matched_words.insert(word.to_lowercase()); - } - - matched_terms.push(TermMatch { - term: term.clone(), - range: (start, end), - description: Some(normalized_term.display().to_string()).filter(|s| !s.is_empty()), - }); - } + let analysis = engine.analyse(text); + let ghosts = analysis + .block + .as_ref() + .map(|block| block.ghost_diagnostics()) + .unwrap_or_default(); + if engine.concept_index().is_empty() { + // No knowledge graph loaded: every word would be "unknown", which is + // noise. Block diagnostics and ghosts do not depend on the graph, so + // keep them. + return KgAnalysis { + diagnostics: analysis.diagnostics, + ghosts, + ..KgAnalysis::empty() + }; } - // Treat any non-empty whitespace-separated token that was not part of a - // match as an unknown term. This is intentionally simple: multi-word - // unknown phrases are not reconstructed here. - let unknown_terms: Vec = text - .split_whitespace() - .map(|word| word.trim_matches(|c: char| !c.is_alphanumeric())) - .filter(|word| { - let lower = word.to_lowercase(); - !lower.is_empty() - && !matched_words.contains(&lower) - && !matched_terms.iter().any(|m| { - m.range.0 <= text.len() - && m.range.1 <= text.len() - && text[m.range.0..m.range.1].to_lowercase().contains(&lower) - }) + let body = &text[..analysis.body_end.byte]; + // A word is unknown when its range overlaps no match. Multi-word + // unknown phrases are not reconstructed: each word is reported alone. + let mut matched = MatchCursor::new(analysis.matches.iter().map(|m| m.range.bytes())); + let unknown_terms = words(body) + .filter(|word| !matched.overlaps(word)) + .map(|bytes| UnknownTerm { + text: body[bytes.clone()].to_string(), + bytes, }) - .map(String::from) .collect(); KgAnalysis { - matched_terms, + matched_terms: analysis.matches, unknown_terms, + diagnostics: analysis.diagnostics, + ghosts, } } +/// Overlap queries of word ranges, in increasing order, against match +/// ranges: linear in words plus matches instead of their product. +/// +/// The match ranges are sorted by start and merged where they overlap or +/// touch (the union, and so every overlap answer, is unchanged), so a +/// single cursor advancing with the words answers each query. +#[derive(Debug)] +struct MatchCursor { + matches: Vec>, + next: usize, + /// Cursor advances plus queries; bounded by matches plus words. + steps: usize, +} + +impl MatchCursor { + fn new(ranges: impl Iterator>) -> Self { + let mut ranges: Vec> = ranges.filter(|range| !range.is_empty()).collect(); + ranges.sort_unstable_by_key(|range| range.start); + let mut matches: Vec> = Vec::with_capacity(ranges.len()); + for range in ranges { + match matches.last_mut() { + Some(last) if range.start <= last.end => last.end = last.end.max(range.end), + _ => matches.push(range), + } + } + Self { + matches, + next: 0, + steps: 0, + } + } + + /// Whether `word` overlaps any match. Words must be queried in + /// increasing, non-overlapping order (as [`words`] yields them). + fn overlaps(&mut self, word: &Range) -> bool { + self.steps += 1; + while self + .matches + .get(self.next) + .is_some_and(|range| range.end <= word.start) + { + self.next += 1; + self.steps += 1; + } + self.matches + .get(self.next) + .is_some_and(|range| range.start < word.end) + } +} + +/// Byte ranges of the words of `text`: whitespace-separated runs with +/// leading and trailing non-alphanumeric characters trimmed. Runs with no +/// alphanumeric character are skipped. +fn words(text: &str) -> impl Iterator> + '_ { + let mut chars = text.char_indices().peekable(); + std::iter::from_fn(move || { + loop { + // Skip whitespace up to the next run. + while chars.next_if(|(_, c)| c.is_whitespace()).is_some() {} + let &(run_start, _) = chars.peek()?; + let mut run_end = run_start; + while let Some((at, c)) = chars.next_if(|(_, c)| !c.is_whitespace()) { + run_end = at + c.len_utf8(); + } + let run = &text[run_start..run_end]; + let trimmed_start = run.trim_start_matches(|c: char| !c.is_alphanumeric()); + let word = trimmed_start.trim_end_matches(|c: char| !c.is_alphanumeric()); + if !word.is_empty() { + let start = run_start + (run.len() - trimmed_start.len()); + return Some(start..start + word.len()); + } + } + }) +} + #[cfg(test)] mod tests { use super::*; use terraphim_types::{NormalizedTerm, NormalizedTermValue, Thesaurus}; - fn sample_thesaurus() -> Thesaurus { + fn sample_engine() -> KgEngine { let mut thesaurus = Thesaurus::new("programming".to_string()); thesaurus.insert( NormalizedTermValue::from("rust"), - NormalizedTerm::with_auto_id(NormalizedTermValue::from("rust programming language")) + NormalizedTerm::new(1, NormalizedTermValue::from("rust programming language")) .with_url("https://rust-lang.org".to_string()), ); thesaurus.insert( NormalizedTermValue::from("async"), - NormalizedTerm::with_auto_id(NormalizedTermValue::from("asynchronous programming")), + NormalizedTerm::new(2, NormalizedTermValue::from("asynchronous programming")), ); thesaurus.insert( NormalizedTermValue::from("tokio"), - NormalizedTerm::with_auto_id(NormalizedTermValue::from("tokio async runtime")), + NormalizedTerm::new(3, NormalizedTermValue::from("tokio async runtime")), ); - thesaurus + KgEngine::new(&thesaurus).expect("sample thesaurus compiles") } #[test] fn test_empty_text_returns_empty() { - let thesaurus = sample_thesaurus(); - let analysis = analyse_kg_document("", &thesaurus); + let analysis = analyse_kg_document("", &sample_engine()); assert!(analysis.is_empty()); } #[test] fn test_empty_thesaurus_returns_empty() { - let thesaurus = Thesaurus::new("empty".to_string()); - let analysis = analyse_kg_document("rust is great", &thesaurus); + let analysis = analyse_kg_document("rust is great", &KgEngine::empty()); assert!(analysis.is_empty()); } #[test] fn test_matched_terms_found() { - let thesaurus = sample_thesaurus(); - let analysis = analyse_kg_document("rust and tokio are great", &thesaurus); + let analysis = analyse_kg_document("rust and tokio are great", &sample_engine()); let terms: Vec = analysis .matched_terms .iter() @@ -160,37 +226,161 @@ mod tests { assert!(terms.contains(&"tokio".to_string())); } + #[test] + fn test_matches_keep_concept_id_and_nterm() { + let analysis = analyse_kg_document("tokio rocks", &sample_engine()); + let tokio = &analysis.matched_terms[0]; + assert_eq!(tokio.concept_id, 3); + assert_eq!(tokio.nterm, "tokio async runtime"); + } + + fn unknown_texts(analysis: &KgAnalysis) -> Vec<&str> { + analysis + .unknown_terms + .iter() + .map(|term| term.text.as_str()) + .collect() + } + #[test] fn test_unknown_terms_found() { - let thesaurus = sample_thesaurus(); - let analysis = analyse_kg_document("rust and xyz are great", &thesaurus); - assert!(analysis.unknown_terms.contains(&"xyz".to_string())); + let analysis = analyse_kg_document("rust and xyz are great", &sample_engine()); + assert_eq!(unknown_texts(&analysis), ["and", "xyz", "are", "great"]); + } + + #[test] + fn test_every_occurrence_has_its_own_range() { + let text = "the rust book\nthe end, (the) the."; + let analysis = analyse_kg_document(text, &sample_engine()); + let the: Vec> = analysis + .unknown_terms + .iter() + .filter(|term| term.text == "the") + .map(|term| term.bytes.clone()) + .collect(); + assert_eq!(the, [0..3, 14..17, 24..27, 29..32]); + for range in the { + assert_eq!(&text[range], "the"); + } + } + + #[test] + fn test_words_inside_matches_are_not_unknown() { + let analysis = analyse_kg_document("Tokio, rust! async?", &sample_engine()); + assert!(analysis.unknown_terms.is_empty(), "{analysis:?}"); + } + + /// The quadratic definition the cursor must agree with. + fn overlaps_naively(matches: &[Range], word: &Range) -> bool { + matches + .iter() + .any(|range| range.start < word.end && word.start < range.end) + } + + #[test] + fn test_cursor_handles_overlapping_adjacent_and_unsorted_matches() { + let matches = [10..14, 0..3, 2..5, 5..7, 30..40, 32..35]; + let words = [ + 0..1, + 3..4, + 7..9, + 9..11, + 14..16, + 18..22, + 25..30, + 33..34, + 40..42, + ]; + // An empty match covers no text, so it is dropped. + let with_empty = matches.iter().cloned().chain(std::iter::once(20..20)); + let mut cursor = MatchCursor::new(with_empty); + assert_eq!(cursor.matches, [0..7, 10..14, 30..40], "merged and sorted"); + for word in &words { + assert_eq!( + cursor.overlaps(word), + overlaps_naively(&matches, word), + "{word:?}" + ); + } + } + + #[test] + fn test_large_document_is_linear_in_words_and_matches() { + // 10k words, every other one a thesaurus term. + let text = "rust filler ".repeat(5_000); + let engine = sample_engine(); + let analysis = engine.analyse(&text); + assert_eq!(analysis.matches.len(), 5_000); + let mut cursor = MatchCursor::new(analysis.matches.iter().map(|m| m.range.bytes())); + let word_ranges: Vec> = words(&text).collect(); + assert_eq!(word_ranges.len(), 10_000); + let unknown = word_ranges + .iter() + .filter(|word| !cursor.overlaps(word)) + .count(); + assert_eq!(unknown, 5_000); + assert!( + cursor.steps <= word_ranges.len() + analysis.matches.len(), + "{} steps", + cursor.steps + ); + let full = analyse_kg_document(&text, &engine); + assert_eq!(full.unknown_terms.len(), 5_000); + assert!(full.unknown_terms.iter().all(|term| term.text == "filler")); + } + + #[test] + fn test_words_trim_punctuation_and_skip_symbols() { + let text = " \"héllo,\" -- 😀 x\r\nend."; + let ranges: Vec<&str> = words(text).map(|range| &text[range]).collect(); + assert_eq!(ranges, ["héllo", "x", "end"]); } #[test] fn test_positions_are_populated() { - let thesaurus = sample_thesaurus(); - let analysis = analyse_kg_document("rust is great", &thesaurus); + let analysis = analyse_kg_document("rust is great", &sample_engine()); let rust_match = analysis .matched_terms .iter() .find(|m| m.term == "rust") .expect("rust should match"); - assert_eq!(rust_match.range, (0, 4)); + assert_eq!(rust_match.range.bytes(), 0..4); + } + + #[test] + fn test_annotation_block_is_not_analysed() { + let text = "rust\n\n```terraphim-alternatives\n{\"version\": 1, \"spans\": [], \"overflow\": \"tokio zzz\"}\n```\n"; + let analysis = analyse_kg_document(text, &sample_engine()); + assert_eq!(analysis.matched_terms.len(), 1); + assert!( + analysis.unknown_terms.is_empty(), + "{:?}", + analysis.unknown_terms + ); + assert!(analysis.diagnostics.is_empty()); + } + + #[test] + fn test_malformed_annotation_block_yields_one_diagnostic() { + let text = "rust\n\n```terraphim-alternatives\n{\"version\": 1}\n"; + let analysis = analyse_kg_document(text, &sample_engine()); + assert_eq!(analysis.diagnostics.len(), 1); + assert_eq!(analysis.matched_terms.len(), 1); } #[test] fn test_analyse_never_panics_on_arbitrary_input() { - let thesaurus = sample_thesaurus(); + let engine = sample_engine(); let inputs = [ "!@#$%^&*()", "rust\n\ntokio\tasync", "", "RUST", "a b c d e f g", + "😀 rust é", ]; for input in inputs { - let _ = analyse_kg_document(input, &thesaurus); + let _ = analyse_kg_document(input, &engine); } } } diff --git a/crates/terraphim_lsp/src/lib.rs b/crates/terraphim_lsp/src/lib.rs index 7529d290f..66fd3143c 100644 --- a/crates/terraphim_lsp/src/lib.rs +++ b/crates/terraphim_lsp/src/lib.rs @@ -1,14 +1,23 @@ //! Language Server Protocol (LSP) support for Terraphim knowledge graphs. //! -//! Provides LSP hover, completion, and diagnostics for KG markdown files, -//! enabling editor support for authoring Terraphim knowledge-graph content. +//! Provides LSP hover, completion, diagnostics (with faded ghosted text), +//! synonym code actions, `[i/n]` inlay hints and `workspace/executeCommand` +//! commands for KG markdown files, enabling editor support for authoring +//! Terraphim knowledge-graph content. The analysis itself lives in the pure, +//! WASM-buildable [`terraphim_lsp_core`] crate, re-exported as [`core`]. +pub mod commands; pub mod completion; +mod convert; pub mod diagnostics; +pub mod handshake; pub mod kg_analysis; pub mod server; +pub mod settings; +pub mod thesaurus; pub use server::TerraphimLspServer; +pub use terraphim_lsp_core as core; #[cfg(test)] mod tests { diff --git a/crates/terraphim_lsp/src/main.rs b/crates/terraphim_lsp/src/main.rs index 676023bc9..e84f6ab2a 100644 --- a/crates/terraphim_lsp/src/main.rs +++ b/crates/terraphim_lsp/src/main.rs @@ -1,13 +1,40 @@ //! Terraphim LSP binary. //! //! Starts a Language Server Protocol server over stdio. The server provides -//! hover, completion, and diagnostics for Terraphim knowledge-graph markdown -//! documents. +//! hover, completion, diagnostics, synonym code actions and inlay hints for +//! Terraphim knowledge-graph markdown documents. +//! +//! The thesaurus comes from the client's `thesaurus` setting, else +//! `--thesaurus `, else the `TERRAPHIM_THESAURUS` environment +//! variable; see [`terraphim_lsp::thesaurus`]. use terraphim_lsp::TerraphimLspServer; +use terraphim_lsp::thesaurus::{LaunchOptions, USAGE, parse_args}; #[tokio::main] async fn main() { env_logger::init(); - TerraphimLspServer::run_stdio().await; + let cli = match parse_args(std::env::args_os().skip(1)) { + Ok(cli) => cli, + Err(error) => { + eprintln!("terraphim-lsp: {error}\n\n{USAGE}"); + std::process::exit(2); + } + }; + if cli.help { + print!("{USAGE}"); + return; + } + if cli.version { + println!("terraphim-lsp {}", env!("CARGO_PKG_VERSION")); + return; + } + if !cli.ignored.is_empty() { + log::info!("terraphim-lsp: ignoring arguments {:?}", cli.ignored); + } + let launch = LaunchOptions::from_process(&cli); + let code = TerraphimLspServer::run_stdio_with_launch_options(launch).await; + // Exit explicitly: tokio would otherwise wait for the blocked read of + // stdin, which an `exit` from a client that keeps the pipe open never ends. + std::process::exit(code); } diff --git a/crates/terraphim_lsp/src/server.rs b/crates/terraphim_lsp/src/server.rs index 40dd2e124..bf3b443dc 100644 --- a/crates/terraphim_lsp/src/server.rs +++ b/crates/terraphim_lsp/src/server.rs @@ -1,21 +1,45 @@ //! LSP server implementation for Terraphim knowledge graphs. //! //! Implements the `LanguageServer` trait from `tower-lsp`, wiring up hover, -//! completion, and diagnostics to the Terraphim KG analysis pipeline. +//! completion, diagnostics, synonym code actions, inlay hints and +//! `workspace/executeCommand` to the pure [`terraphim_lsp_core`] analysis +//! engine. use std::collections::HashMap; -use std::sync::Arc; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, PoisonError}; +use serde_json::Value; use tokio::sync::RwLock; use tower_lsp::jsonrpc::Result; use tower_lsp::lsp_types::*; use tower_lsp::{Client, LanguageServer, LspService, Server}; +use terraphim_lsp_core::{ + CutId, GhostEdited, GhostError, KgEngine, LabAction, LabConfig, LabFinding, LineIndex, + OverflowEdited, OverflowError, ReanchorOutcome, Source, StoredAlternative, StoredSpan, + TextRange, TrimLevel, TrimPlan, TrimView, add_alternative, export_clean, + export_knowledge_graph, ghost_availability, ghost_range, lab_findings, overflow_paragraphs, + pull_back, reanchor_block, revive_range, select_alternative, split_annotation_block, + stash_range, stored_anchor_ranges, stored_span_at, trim_cuts, trim_done, trim_plan_for, + trim_view, +}; use terraphim_types::Thesaurus; +use crate::commands::{ + self, AddAlternativeArgs, DocumentArgs, ExportArgs, LabMarkArgs, TrimKeepArgs, + TrimMakeCutsArgs, TrimNextArgs, TrimPreviewArgs, +}; use crate::completion::{build_completions, word_at_position}; -use crate::diagnostics::build_diagnostics_with_positions; +use crate::convert; +use crate::diagnostics::{core_diagnostics, ghost_diagnostics, unknown_term_diagnostics}; +use crate::handshake::{self, InitializeHint}; use crate::kg_analysis::analyse_kg_document; +use crate::settings::{LabSettings, LabTrigger, ServerSettings}; +use crate::thesaurus::{ + LaunchOptions, effective_thesaurus_path, load_thesaurus, thesaurus_setting, +}; /// Terraphim LSP server backed by a knowledge-graph thesaurus. /// @@ -23,22 +47,371 @@ use crate::kg_analysis::analyse_kg_document; /// /// - `textDocument/hover` - concept descriptions for matched KG terms /// - `textDocument/completion` - thesaurus term suggestions -/// - `textDocument/diagnostic` - warnings for unknown terms +/// - `textDocument/diagnostic` for clients that pull, pushed +/// `publishDiagnostics` for the others (never both) - a +/// malformed `terraphim-alternatives` annotation block, faded +/// (`Unnecessary`) hints over ghosted text and, when the `unknownTerms` +/// setting is on, a warning on every unknown-word occurrence +/// - `textDocument/codeAction` - "Replace with X" for every other synonym of +/// the KG term at the cursor, keeping capitalisation and fixing `a`/`an`, +/// and "Apply fix: X" for Lab typo and punctuation marks +/// - Lab marks and trim candidates as diagnostics, computed on demand +/// (`terraphim.lab.mark`, `terraphim.trim.preview`) and, for the +/// configured `lab.actions`, on open and save; never on every keystroke +/// - the trim review (R-8.4, R-8.5): a status card +/// (`window/showMessageRequest`) with "Make the cuts" +/// (`workspace/applyEdit`), "Walk through" (`window/showDocument`) and +/// "Done", and "Keep" code actions on faded cuts +/// - `textDocument/inlayHint` - `[i/n]` after each KG term with alternatives, +/// when the `inlayHints` setting is on (off by default) +/// - `workspace/executeCommand` - the commands in [`commands::ALL`] +/// +/// Settings come from `initializationOptions` and +/// `workspace/didChangeConfiguration`; see [`ServerSettings`]. The +/// thesaurus is loaded from the `thesaurus` setting, the `--thesaurus` flag +/// or `TERRAPHIM_THESAURUS` (see [`crate::thesaurus`]) and reloaded when the +/// path changes. #[derive(Debug)] pub struct TerraphimLspServer { client: Client, + /// The knowledge graph in use. A std lock: it is only held to clone or + /// replace the `Arc`, never across an `.await`. + kg: std::sync::RwLock>, + /// The knowledge graph the server was constructed with, used while no + /// thesaurus path is configured. + base_kg: Arc, + /// Launch-time thesaurus path and home directory. + launch: LaunchOptions, + /// The `thesaurus` member of `initializationOptions`, kept under later + /// `didChangeConfiguration` settings that do not name one. + initial_thesaurus: std::sync::RwLock>, + /// Bumped by every thesaurus load; a load installs its result only if + /// no newer load started meanwhile. + kg_loads: AtomicU64, + cut_operations: Arc, + documents: Arc>>, + /// Last acknowledged generated text, used to refuse changed output buffers. + exported: RwLock>, + /// Whether the client accepts `WorkspaceEdit.documentChanges` + /// (`workspace.workspaceEdit.documentChanges`), read at `initialize`. + versioned_edits: Arc, + /// Whether the client accepts `workspace/inlayHint/refresh`, read at + /// `initialize`. + inlay_hint_refresh: AtomicBool, + /// Whether the client shows `window/showMessageRequest` with buttons + /// (`window.showMessage.messageActionItem`), read at `initialize`. The + /// trim status card is a prompt then, else a plain message. + prompts: Arc, + /// Whether the client takes `window/showDocument` + /// (`window.showDocument.support`), read at `initialize`; "Walk + /// through" selects the next cut with it. + show_document: Arc, + /// Whether the client takes `workspace/applyEdit` + /// (`workspace.applyEdit`), read at `initialize`; "Make the cuts" + /// sends its edit with it, else returns the edit. + apply_edit: Arc, + /// Whether the client can create files in a workspace edit + /// (`workspace.workspaceEdit.resourceOperations` has `create`), read at + /// `initialize`; the clean export is written with it. + create_file: AtomicBool, + /// Woken whenever a document's text is replaced, so a save that edits the + /// block can wait for the client's change before refreshing the Lab. + changed: Arc, + /// Whether the client pulls diagnostics (`textDocument.diagnostic`), + /// read at `initialize`. Then the server never pushes + /// `publishDiagnostics`, so each diagnostic is shown once. + pull_diagnostics: AtomicBool, + /// Whether the client accepts `workspace/diagnostic/refresh`, decided at + /// `initialize` from [`Self::refresh_hint`] and the typed params; used in pull mode when results change outside a + /// client request (Lab runs, clears, settings and thesaurus changes). + diagnostic_refresh: AtomicBool, + /// Whether the raw `initialize` advertised refresh support under the + /// spec key `workspace.diagnostics`, which the typed params drop (see + /// [`crate::handshake`]). Set by the stdio entry points. + refresh_hint: Option, + /// Current settings. A std lock: it is never held across an `.await`. + settings: std::sync::RwLock, + /// The Lab engine's configuration (embedded default lists), or `None` + /// if they failed to load. + lab_config: Option>, +} + +/// A thesaurus and the engine compiled from it, swapped as one unit so +/// completion and matching never disagree. +#[derive(Debug)] +struct Kg { thesaurus: Thesaurus, - documents: Arc>>, + engine: KgEngine, + /// The file it was loaded from (even if loading failed and the engine + /// is empty); `None` for the thesaurus given to the constructor. + path: Option, +} + +impl Kg { + fn empty_for(path: PathBuf) -> Self { + Self { + thesaurus: Thesaurus::new("empty".to_string()), + engine: KgEngine::empty(), + path: Some(path), + } + } +} + +/// An open document: its full text, the version the client last sent, and +/// the Lab and trim results computed for that text. +#[derive(Debug, Clone, Default)] +struct OpenDocument { + text: String, + version: i32, + /// Identity of this open/close lifecycle, preserved by snapshots and edits. + incarnation: Arc<()>, + /// Automatic reanchor reserved for this exact document version/incarnation. + reanchoring: Option, + /// Lab actions requested for this document through `terraphim.lab.mark`, + /// sorted. Kept across edits until `terraphim.lab.clear`. + lab_actions: Vec, + /// Lab findings for the current text. Cleared on every change, because + /// their ranges would be stale. + lab: Vec, + /// The trim level previewed through `terraphim.trim.preview`, until + /// `terraphim.trim.clear` (or "Trim: Original"), or until an edit + /// leaves exactly the text "Make the cuts" produces. + trim_level: Option, + /// The cuts kept with `terraphim.trim.keep`. Cut ids belong to one plan, + /// so they are forgotten with it on every change. + trim_kept: Vec, + /// Every trim level planned for the current text. Cleared on every + /// change; switching level and keeping cuts reuse it. + trim_plan: Option>, + /// The review of the trim level with its kept cuts, for the current + /// text: the faded spans, the cuts and the status card numbers. Cleared + /// on every change. + trim: Option>, + /// The text "Make the cuts" turns the current text into. + trim_made: Option>, + /// The start (byte offset) of the span the card's "Walk through" last + /// showed; the next one follows it. + trim_walk: Option, + /// A "Make the cuts" edit sent with `workspace/applyEdit` and not yet + /// answered as failed. While one is pending no second cut is sent. + trim_cutting: Option, + /// Bumped whenever what the Lab results depend on changes: the text, + /// the requested actions, the trim level or the Lab settings. A Lab run + /// installs its results only if the generation it snapshotted is still + /// current, so runs finishing out of order never overwrite newer state. + generation: u64, +} + +/// A "Make the cuts" edit sent to the client with `workspace/applyEdit`. +/// +/// The preview ends when the client confirms the edit (`applied: true`) and +/// the document has changed since the edit was built, in either order. So a +/// client whose text differs from [`OpenDocument::trim_made`] (a trailing +/// newline, say) still ends it, while typing during a request the client +/// then rejects does not. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PendingCut { + /// Unique across preview replacements at the same document version. + operation: u64, + /// The version the edit was built for. + version: i32, + /// Whether the client answered `applied: true`. + applied: bool, +} + +/// A snapshot of one document for a Lab run. +#[derive(Debug, Clone)] +struct LabJob { + text: String, + version: i32, + generation: u64, + actions: Vec, + trim_level: Option, + trim_kept: Vec, + /// The plan of `text`, when one was already computed. + trim_plan: Option>, +} + +/// What became of a Lab run's results. +#[derive(Debug)] +enum Installed { + /// Installed; the updated document. + Yes(OpenDocument), + /// Dropped: the document was edited (now at this version) or closed. + Edited(Option), + /// Dropped: the requested Lab state changed on the same version; a + /// newer run covers it. + Superseded, +} + +/// The results of a [`LabJob`]. +#[derive(Debug, Clone, Default)] +struct LabResults { + lab: Vec, + trim: Option, +} + +/// A trim review computed for one text: the plan, the view of the level +/// with its kept cuts, and the text "Make the cuts" would leave. +#[derive(Debug, Clone)] +struct TrimReview { + plan: Arc, + view: TrimView, + made: String, +} + +impl TrimReview { + fn compute(text: &str, plan: Arc, level: TrimLevel, kept: &[CutId]) -> Self { + Self { + view: trim_view(text, &plan, level, kept), + made: trim_cuts(text, &plan, level, kept).text, + plan, + } + } + + /// Install into `document` (computed for its current text). + fn install(self, document: &mut OpenDocument) { + document.trim_plan = Some(self.plan); + document.trim = Some(Arc::new(self.view)); + document.trim_made = Some(self.made.into()); + } +} + +impl LabJob { + /// Run the configured and requested actions and the trim preview. CPU + /// bound and whole-document; called on the blocking pool. + fn run(&self, config: &LabConfig) -> LabResults { + let lab = if self.actions.is_empty() { + Vec::new() + } else { + lab_findings(&self.text, config, &self.actions) + }; + let trim = self.trim_level.map(|level| { + let plan = self + .trim_plan + .clone() + .unwrap_or_else(|| Arc::new(trim_plan_for(&self.text, config))); + TrimReview::compute(&self.text, plan, level, &self.trim_kept) + }); + LabResults { lab, trim } + } } +/// The code-action kind of the Lab fixes. +const FIX_KIND: CodeActionKind = CodeActionKind::QUICKFIX; + +/// The code-action kind of the synonym replacements. +const REPLACE_KIND: CodeActionKind = CodeActionKind::REFACTOR_REWRITE; + +/// The code-action kind of the trim actions. Clients that cannot add +/// palette commands (Zed) reach the `terraphim.trim.*` commands through +/// these command-only actions. +const TRIM_KIND: CodeActionKind = CodeActionKind::new("refactor.terraphim.trim"); + +/// The code-action kind of "Ghost it" and "Revive" (R-5.1, R-5.2). +/// +/// Not `refactor.rewrite`: the body text never changes, only the annotation +/// block does, so these are not rewrites of the writer's words. A kind of +/// their own, beside [`TRIM_KIND`], lets a client ask for or hide them with +/// `only`, and `refactor` still includes them. +const GHOST_KIND: CodeActionKind = CodeActionKind::new("refactor.terraphim.ghost"); + +/// The code-action kind of the Lab actions. Like [`TRIM_KIND`], command-only +/// so that Zed, which runs only the commands code actions carry, reaches +/// `terraphim.lab.mark` and `terraphim.lab.clear`. +const LAB_KIND: CodeActionKind = CodeActionKind::new("refactor.terraphim.lab"); + +/// The code-action kind of "Replace with" a stored alternative (R-2.2, +/// R-4.4). +/// +/// Not `refactor.rewrite`: the action rewrites the annotation block as well +/// as the words (the span's `active` and anchor), so it is not a plain +/// rewrite. A kind of its own, beside [`GHOST_KIND`], lets a client ask for +/// or hide it with `only`, and `refactor` still includes it. +const ALTERNATIVE_KIND: CodeActionKind = CodeActionKind::new("refactor.terraphim.alternative"); + +/// The code-action kind of "Stash in Overflow" and "Pull back" (R-6.3, R-6.4). +/// +/// Not `refactor.move`: the words move between the body and the overflow +/// kept in the annotation block, which only this server understands. A kind +/// of its own lets a client ask for or hide them with `only`, and +/// `refactor` still includes them. +const OVERFLOW_KIND: CodeActionKind = CodeActionKind::new("refactor.terraphim.overflow"); + +/// The code-action kind of "Export clean Markdown" (R-9.3): a source action, +/// not a refactor, because it leaves the document alone. +const EXPORT_KIND: CodeActionKind = CodeActionKind::new("source.terraphim.export"); + +/// How many overflow paragraphs "Pull back" offers at once. +const PULL_BACK_LIMIT: usize = 5; + +/// The status card's buttons (R-8.4). +const CARD_MAKE_CUTS: &str = "Make the cuts"; +const CARD_WALK: &str = "Walk through"; +const CARD_DONE: &str = "Done"; + +/// The status card's hint. Zed has no clickable fades, so a cut is kept +/// through its code action. +const CARD_HINT: &str = "Faded words would go. Keep one with the Keep action."; + impl TerraphimLspServer { /// Create a new LSP server instance tied to the given LSP client and /// knowledge-graph thesaurus. + /// + /// If the thesaurus cannot be compiled into a matcher, the error is logged + /// and the server runs without KG matches rather than failing to start. + /// + /// A `thesaurus` setting from the client replaces this thesaurus. pub fn new(client: Client, thesaurus: Thesaurus) -> Self { + Self::build(client, thesaurus, LaunchOptions::default()) + } + + /// Create a server that starts with an empty thesaurus and loads the + /// launch thesaurus (`--thesaurus` / `TERRAPHIM_THESAURUS`, resolved + /// into `launch`) at `initialize`, unless the client's `thesaurus` + /// setting names another file. + pub fn with_launch_options(client: Client, launch: LaunchOptions) -> Self { + Self::build(client, Thesaurus::new("empty".to_string()), launch) + } + + fn build(client: Client, thesaurus: Thesaurus, launch: LaunchOptions) -> Self { + let engine = KgEngine::new(&thesaurus).unwrap_or_else(|error| { + log::error!("terraphim_lsp: KG engine unavailable: {error}"); + KgEngine::empty() + }); + log_skipped_patterns(&engine); + let base_kg = Arc::new(Kg { + thesaurus, + engine, + path: None, + }); Self { client, - thesaurus, + kg: std::sync::RwLock::new(Arc::clone(&base_kg)), + base_kg, + launch, + initial_thesaurus: std::sync::RwLock::new(None), + kg_loads: AtomicU64::new(0), + cut_operations: Arc::new(AtomicU64::new(0)), documents: Arc::new(RwLock::new(HashMap::new())), + exported: RwLock::new(HashMap::new()), + versioned_edits: Arc::new(AtomicBool::new(false)), + inlay_hint_refresh: AtomicBool::new(false), + prompts: Arc::new(AtomicBool::new(false)), + show_document: Arc::new(AtomicBool::new(false)), + apply_edit: Arc::new(AtomicBool::new(false)), + create_file: AtomicBool::new(false), + changed: Arc::new(tokio::sync::Notify::new()), + pull_diagnostics: AtomicBool::new(false), + diagnostic_refresh: AtomicBool::new(false), + refresh_hint: None, + settings: std::sync::RwLock::new(ServerSettings::default()), + lab_config: LabConfig::with_defaults() + .inspect_err(|error| { + log::error!("terraphim_lsp: Lab engine unavailable: {error}"); + }) + .ok() + .map(Arc::new), } } @@ -49,59 +422,1746 @@ impl TerraphimLspServer { Self::new(client, Thesaurus::new("empty".to_string())) } - /// Run the LSP server over stdio using an empty thesaurus. + /// Record whether the raw `initialize` advertised + /// `workspace/diagnostic/refresh` support under the spec key (see + /// [`crate::handshake`]). The stdio entry points set it; a server built + /// directly relies on the typed (legacy) key alone. + #[must_use] + pub fn with_refresh_hint(mut self, hint: InitializeHint) -> Self { + self.refresh_hint = hint.diagnostic_refresh; + self + } + + /// Run the LSP server over stdio using an empty thesaurus, until the + /// client sends `exit` or closes stdin; the process exit code (see + /// [`Self::serve_stdio`]). /// /// This is the entry point for the `terraphim-lsp` binary. For programmatic /// use with a custom thesaurus, construct the server via [`LspService::new`] /// and [`Self::new`]. - pub async fn run_stdio() { - let (stdin, stdout) = (tokio::io::stdin(), tokio::io::stdout()); - let (service, socket) = LspService::new(Self::new_with_empty_thesaurus); - Server::new(stdin, stdout, socket).serve(service).await; + pub async fn run_stdio() -> i32 { + Self::serve_stdio(Self::new_with_empty_thesaurus).await + } + + /// Run the LSP server over stdio with the given thesaurus; the process + /// exit code. + pub async fn run_stdio_with_thesaurus(thesaurus: Thesaurus) -> i32 { + Self::serve_stdio(move |client| Self::new(client, thesaurus)).await + } + + /// Run the LSP server over stdio with launch options (see + /// [`Self::with_launch_options`]). The `terraphim-lsp` binary uses this; + /// it exits the process with the code returned. + pub async fn run_stdio_with_launch_options(launch: LaunchOptions) -> i32 { + Self::serve_stdio(move |client| Self::with_launch_options(client, launch)).await + } + + /// Serve stdio, first reading the raw `initialize` for the capabilities + /// the typed params lose ([`crate::handshake`]) and replaying it in + /// front of the rest of stdin. + /// + /// Returns when the client sends `exit` or closes stdin. tower-lsp + /// notices an exit only when the next message arrives, and the blocked + /// read of stdin then keeps the process alive, so the `exit` notification + /// is watched for here ([`ExitWatch`]). The code follows LSP 3.17: 0 after + /// a `shutdown` request (and at end of input), 1 for an `exit` without + /// one. The caller exits the process with it. + async fn serve_stdio(build: impl FnOnce(Client) -> Self) -> i32 { + let mut stdin = tokio::io::stdin(); + let (hint, buffered) = handshake::peek_initialize(&mut stdin).await; + let input = tokio::io::AsyncReadExt::chain(std::io::Cursor::new(buffered), stdin); + let (service, socket) = + LspService::new(move |client| build(client).with_refresh_hint(hint)); + let exited = Arc::new(tokio::sync::Notify::new()); + let shutdown_requested = Arc::new(AtomicBool::new(false)); + let exit_seen = Arc::new(AtomicBool::new(false)); + let service = ExitWatch { + inner: service, + exited: Arc::clone(&exited), + shutdown_requested: Arc::clone(&shutdown_requested), + exit_seen: Arc::clone(&exit_seen), + }; + // 1 for an `exit` without a `shutdown` before it, whether the client + // then keeps the pipe open or closes it; 0 otherwise. + let code = || { + i32::from( + exit_seen.load(Ordering::SeqCst) && !shutdown_requested.load(Ordering::SeqCst), + ) + }; + let serve = Server::new(input, tokio::io::stdout(), socket).serve(service); + tokio::pin!(serve); + tokio::select! { + () = &mut serve => code(), + () = exited.notified() => { + // Let responses already queued (the answer to `shutdown` + // just before `exit`) reach the client. + let _ = tokio::time::timeout(EXIT_GRACE, &mut serve).await; + code() + } + } + } + + /// The knowledge graph in use. + fn kg(&self) -> Arc { + Arc::clone(&self.kg.read().unwrap_or_else(PoisonError::into_inner)) + } + + /// The path of the thesaurus file in use, if one was loaded from a file + /// (successfully or not). + pub fn thesaurus_path(&self) -> Option { + self.kg().path.clone() + } + + /// The number of thesaurus terms in use (0 when none is loaded). + pub fn thesaurus_len(&self) -> usize { + self.kg().thesaurus.len() + } + + /// Make the thesaurus match `settings`: load the effective path + /// ([`thesaurus_setting`] over the `initializationOptions` value, then + /// [`effective_thesaurus_path`]) if it differs from the one in use, or + /// return to the constructor's thesaurus when no path is configured. + /// Returns whether the thesaurus was replaced. + /// + /// A missing or invalid file is logged and shown once to the user + /// (`window/showMessage`, Warning); the server keeps running with an + /// empty engine for that path. The same path is not retried until it + /// changes. + async fn apply_thesaurus_setting(&self, settings: &ServerSettings) -> bool { + let initial = self + .initial_thesaurus + .read() + .unwrap_or_else(PoisonError::into_inner) + .clone(); + let wanted = effective_thesaurus_path( + thesaurus_setting(settings.thesaurus.as_deref(), initial.as_deref()), + self.launch.thesaurus.as_deref(), + self.launch.home.as_deref(), + ); + if self.kg().path == wanted { + return false; + } + let load = self.kg_loads.fetch_add(1, Ordering::SeqCst) + 1; + let kg = match wanted { + None => Arc::clone(&self.base_kg), + Some(path) => Arc::new(self.load_kg(path).await), + }; + let mut current = self.kg.write().unwrap_or_else(PoisonError::into_inner); + if self.kg_loads.load(Ordering::SeqCst) != load { + // A newer load started while this one ran; it decides. + return false; + } + *current = kg; + true } - /// Run the LSP server over stdio with the given thesaurus. - pub async fn run_stdio_with_thesaurus(thesaurus: Thesaurus) { - let (stdin, stdout) = (tokio::io::stdin(), tokio::io::stdout()); - let (service, socket) = LspService::new(move |client| Self::new(client, thesaurus.clone())); - Server::new(stdin, stdout, socket).serve(service).await; + /// Load the thesaurus at `path` on the blocking pool; on failure, report + /// it and return an empty knowledge graph for that path. + async fn load_kg(&self, path: PathBuf) -> Kg { + let task_path = path.clone(); + let result = tokio::task::spawn_blocking(move || load_thesaurus(&task_path)).await; + match result { + Ok(Ok(loaded)) => { + log::info!( + "terraphim_lsp: loaded {} thesaurus terms from {}", + loaded.thesaurus.len(), + path.display() + ); + log_skipped_patterns(&loaded.engine); + Kg { + thesaurus: loaded.thesaurus, + engine: loaded.engine, + path: Some(path), + } + } + Ok(Err(error)) => { + self.report_load_failure(&path, &error).await; + Kg::empty_for(path) + } + Err(error) => { + self.report_load_failure(&path, &error).await; + Kg::empty_for(path) + } + } } - /// Re-analyse a document and publish diagnostics to the client. - async fn publish_diagnostics(&self, uri: &Url, text: &str) { - let analysis = analyse_kg_document(text, &self.thesaurus); - let diagnostics = build_diagnostics_with_positions(&analysis, text); + async fn report_load_failure(&self, path: &Path, error: &(dyn std::fmt::Display + Sync)) { + log::error!( + "terraphim_lsp: cannot load thesaurus {}: {error}", + path.display() + ); self.client - .publish_diagnostics(uri.clone(), diagnostics, None) + .show_message( + MessageType::WARNING, + format!( + "terraphim-lsp: cannot load thesaurus {}: {error}. Hover, synonym \ + actions and inlay hints stay off until the `thesaurus` setting \ + names a valid thesaurus JSON file.", + path.display() + ), + ) .await; } + + /// The current settings. + pub fn settings(&self) -> ServerSettings { + self.settings + .read() + .unwrap_or_else(PoisonError::into_inner) + .clone() + } + + fn set_settings(&self, settings: ServerSettings) { + *self + .settings + .write() + .unwrap_or_else(PoisonError::into_inner) = settings; + } + + /// The current text of an open document. + async fn document_text(&self, uri: &Url) -> Option { + self.documents + .read() + .await + .get(uri) + .map(|document| document.text.clone()) + } + + /// Record the latest text and version of a document. Requested Lab + /// actions and the trim level survive; their results, computed for the + /// old text, are dropped. + async fn store_document(&self, uri: &Url, text: &str, version: i32) { + let mut documents = self.documents.write().await; + if documents + .get(uri) + .is_some_and(|document| version <= document.version) + { + log::debug!("terraphim_lsp: ignored non-increasing document version {version}"); + return; + } + let document = documents.entry(uri.clone()).or_default(); + document.text = text.to_string(); + document.version = version; + document.reanchoring = None; + document.generation += 1; + document.lab.clear(); + let cut = document.trim_made.as_deref() == Some(text) + || document + .trim_cutting + .is_some_and(|pending| pending.applied && version > pending.version); + if cut { + // "Make the cuts" was applied: the preview is over. + end_preview(document); + } + document.trim_kept.clear(); + document.trim_plan = None; + document.trim = None; + document.trim_made = None; + document.trim_walk = None; + drop(documents); + self.changed.notify_waiters(); + } + + /// What a Lab run needs from a document, taken under a brief lock so + /// the (whole-document) computation runs without holding any lock. + fn lab_job(&self, document: &OpenDocument) -> LabJob { + let mut actions = self.settings().lab.actions; + actions.extend(document.lab_actions.iter().copied()); + actions.sort_unstable(); + actions.dedup(); + LabJob { + text: document.text.clone(), + version: document.version, + generation: document.generation, + actions, + trim_level: document.trim_level, + trim_kept: document.trim_kept.clone(), + trim_plan: document.trim_plan.clone(), + } + } + + /// Run a Lab job on the blocking pool. `None` when the Lab engine is + /// unavailable or the task failed. + async fn run_lab_job(&self, job: LabJob) -> Option { + let config = Arc::clone(self.lab_config.as_ref()?); + tokio::task::spawn_blocking(move || job.run(&config)) + .await + .inspect_err(|error| log::error!("terraphim_lsp: Lab run failed: {error}")) + .ok() + } + + /// Install the results of `job` for `uri`, unless the document was + /// edited or closed, or its requested Lab state changed, since the job + /// was snapshotted: then they are dropped. + async fn install_lab(&self, uri: &Url, job: &LabJob, results: LabResults) -> Installed { + let mut documents = self.documents.write().await; + let Some(document) = documents.get_mut(uri) else { + return Installed::Edited(None); + }; + if document.version != job.version { + return Installed::Edited(Some(document.version)); + } + if document.generation != job.generation { + return Installed::Superseded; + } + document.lab = results.lab; + match results.trim { + Some(review) => review.install(document), + None => { + document.trim = None; + document.trim_made = None; + } + } + Installed::Yes(document.clone()) + } + + /// Recompute the Lab results of an open document, then publish. Results + /// overtaken by an edit or a newer request are dropped; whatever + /// overtook them recomputes. + async fn refresh_lab_and_publish(&self, uri: &Url) { + let job = { + let documents = self.documents.read().await; + documents.get(uri).map(|document| self.lab_job(document)) + }; + let Some(job) = job else { + return; + }; + let Some(results) = self.run_lab_job(job.clone()).await else { + return; + }; + if let Installed::Yes(document) = self.install_lab(uri, &job, results).await { + let had_work = !job.actions.is_empty() || job.trim_level.is_some(); + if had_work || !self.pull_diagnostics.load(Ordering::Relaxed) { + // A pulling client already pulled for the edit or open; ask + // it again only when the Lab run could have changed the + // results. + self.publish(uri, &document).await; + } + } + } + + /// The open document `uri`, checked against an expected version. + async fn checked_document(&self, uri: &Url, version: Option) -> Result { + let document = self + .documents + .read() + .await + .get(uri) + .cloned() + .ok_or_else(|| commands::invalid_params(format!("{uri} is not open")))?; + match version { + Some(version) if version != document.version => { + Err(commands::content_modified(version, document.version)) + } + _ => Ok(document), + } + } + + /// Apply `update` to the requested Lab state of the open document `uri` + /// (if it is still at `version`), recompute its Lab results outside the + /// lock and publish. + /// + /// The command result always matches the installed state: + /// - if the document is edited while the results are computed, they are + /// dropped and the request fails with `ContentModified`; + /// - if another Lab command changes the requested state of the same + /// version meanwhile, the run is superseded and this command + /// recomputes from the newer state (which includes its own change) + /// until its results are the ones installed. + async fn update_lab( + &self, + uri: &Url, + version: Option, + update: impl FnOnce(&mut OpenDocument), + ) -> Result { + let job = self.begin_lab_update(uri, version, update).await?; + self.finish_lab_update(uri, job).await + } + + /// The first half of [`Self::update_lab`]: check the version, apply + /// `update`, bump the generation and snapshot the job, under a brief + /// lock. + async fn begin_lab_update( + &self, + uri: &Url, + version: Option, + update: impl FnOnce(&mut OpenDocument), + ) -> Result { + if self.lab_config.is_none() { + return Err(commands::invalid_params("the Lab engine is unavailable")); + } + let mut documents = self.documents.write().await; + let document = documents + .get_mut(uri) + .ok_or_else(|| commands::invalid_params(format!("{uri} is not open")))?; + if let Some(version) = version + && version != document.version + { + return Err(commands::content_modified(version, document.version)); + } + update(document); + document.generation += 1; + Ok(self.lab_job(document)) + } + + /// The second half of [`Self::update_lab`]: compute without a lock, + /// install (recomputing while superseded) and publish. + async fn finish_lab_update(&self, uri: &Url, mut job: LabJob) -> Result { + loop { + let results = self + .run_lab_job(job.clone()) + .await + .ok_or_else(|| commands::invalid_params("the Lab run failed"))?; + match self.install_lab(uri, &job, results).await { + Installed::Yes(document) => { + self.publish(uri, &document).await; + return Ok(document); + } + Installed::Edited(Some(held)) => { + return Err(commands::content_modified(job.version, held)); + } + Installed::Edited(None) => { + return Err(commands::invalid_params(format!("{uri} is not open"))); + } + Installed::Superseded => { + let documents = self.documents.read().await; + let document = documents + .get(uri) + .ok_or_else(|| commands::invalid_params(format!("{uri} is not open")))?; + if document.version != job.version { + return Err(commands::content_modified(job.version, document.version)); + } + job = self.lab_job(document); + } + } + } + } + + /// A workspace edit applying `edits` to version `version` of `uri`. + /// + /// Clients that accept `documentChanges` get a versioned + /// `TextDocumentEdit`, so a stale action is rejected instead of applied + /// to text it was not computed for; others get plain `changes`. + fn workspace_edit(&self, uri: &Url, version: i32, edits: Vec) -> WorkspaceEdit { + workspace_edit( + uri, + version, + edits, + self.versioned_edits.load(Ordering::Relaxed), + ) + } + + /// "Replace with X" code actions for the KG term at `position`. + fn replacement_actions( + &self, + uri: &Url, + document: &OpenDocument, + position: Position, + ) -> Vec { + let text = document.text.as_str(); + let index = LineIndex::new(text); + let offset = convert::byte_offset(&index, position); + let Some(set) = self.kg().engine.alternatives_at(text, offset) else { + return Vec::new(); + }; + // A plain rewrite of text inside a stored span or ghost (the term, or + // its a/an fix-up) would leave that anchor stale: the span's own + // "Replace with" actions and the hover cover it instead. + let anchors = stored_anchor_ranges(text); + set.replacements + .iter() + .filter(|replacement| { + !replacement + .edits + .iter() + .any(|edit| anchors.iter().any(|anchor| overlaps(edit.range, *anchor))) + }) + .map(|replacement| { + let edits = replacement + .edits + .iter() + .map(|edit| convert::text_edit(&index, edit)) + .collect(); + CodeAction { + title: format!("Replace with {}", replacement.text), + kind: Some(REPLACE_KIND), + edit: Some(self.workspace_edit(uri, document.version, edits)), + ..CodeAction::default() + } + }) + .collect() + } + + /// "Replace with" each inactive alternative of the stored span at + /// `position` (R-2.2, R-4.4): one versioned workspace edit that swaps + /// the body text and records the new active alternative in the block, + /// as the editor does. Nothing when the block cannot be rewritten (it is + /// malformed, or an anchor cannot be placed). + fn stored_alternative_actions( + &self, + uri: &Url, + document: &OpenDocument, + position: Position, + ) -> Vec { + let text = document.text.as_str(); + let index = LineIndex::new(text); + let offset = convert::byte_offset(&index, position); + let Some(span) = stored_span_at(text, offset) else { + return Vec::new(); + }; + span.alts + .iter() + .filter(|alt| alt.index != span.active) + .filter_map(|alt| { + let selected = select_alternative(text, &span.span_id, alt.index).ok()?; + let edits = selected + .edits + .iter() + .map(|edit| convert::text_edit(&index, edit)) + .collect(); + Some(CodeAction { + title: format!("Replace with {} ({})", alt.text, provenance(alt)), + kind: Some(ALTERNATIVE_KIND), + edit: Some(self.workspace_edit(uri, document.version, edits)), + ..CodeAction::default() + }) + }) + .collect() + } + + /// Every diagnostic for a document: unknown terms (when enabled), a + /// malformed annotation block, ghost hints (when enabled), Lab marks and + /// trim candidates. Pushed and pulled diagnostics both come from here, so + /// they always agree. + fn diagnostics_for(&self, document: &OpenDocument) -> Vec { + let text = document.text.as_str(); + let settings = self.settings(); + let analysis = analyse_kg_document(text, &self.kg().engine); + let mut diagnostics = if settings.unknown_terms { + unknown_term_diagnostics(&analysis, text) + } else { + Vec::new() + }; + diagnostics.extend(core_diagnostics(&analysis, text)); + if settings.ghost_diagnostics { + diagnostics.extend(ghost_diagnostics(&analysis, text)); + } + let trim = document + .trim + .as_ref() + .map_or(&[][..], |view| &view.diagnostics); + if !document.lab.is_empty() || !trim.is_empty() { + let index = LineIndex::new(text); + let lab = document.lab.iter().map(|finding| &finding.diagnostic); + diagnostics.extend( + lab.chain(trim) + .map(|diagnostic| convert::diagnostic(&index, diagnostic)), + ); + } + diagnostics + } + + /// Push a document's diagnostics, unless the client pulls them: a + /// client that pulls re-requests after its own edits and opens, and + /// would show pushed diagnostics a second time. + async fn push(&self, uri: &Url, document: &OpenDocument) { + if self.pull_diagnostics.load(Ordering::Relaxed) { + return; + } + let diagnostics = self.diagnostics_for(document); + self.send_push(uri.clone(), diagnostics, Some(document.version)) + .await; + } + + /// The only place `publishDiagnostics` is sent: nothing in pull mode. + async fn send_push(&self, uri: Url, diagnostics: Vec, version: Option) { + if self.pull_diagnostics.load(Ordering::Relaxed) { + return; + } + self.client + .publish_diagnostics(uri, diagnostics, version) + .await; + } + + /// Push the diagnostics of the open document `uri` (push mode only). + async fn push_open(&self, uri: &Url) { + let document = self.documents.read().await.get(uri).cloned(); + if let Some(document) = document { + self.push(uri, &document).await; + } + } + + /// A document's diagnostics changed without a client edit (a Lab run, + /// a clear): push them, or in pull mode ask the client to pull again. + async fn publish(&self, uri: &Url, document: &OpenDocument) { + if self.pull_diagnostics.load(Ordering::Relaxed) { + self.request_diagnostic_refresh(); + } else { + self.push(uri, document).await; + } + } + + /// Send `workspace/diagnostic/refresh` when the client pulls and + /// accepts it. Spawned, so no handler waits on the client's reply. + fn request_diagnostic_refresh(&self) { + if !(self.pull_diagnostics.load(Ordering::Relaxed) + && self.diagnostic_refresh.load(Ordering::Relaxed)) + { + return; + } + let client = self.client.clone(); + tokio::spawn(async move { + if let Err(error) = client.workspace_diagnostic_refresh().await { + log::debug!("terraphim_lsp: diagnostic refresh failed: {error}"); + } + }); + } + + /// "Apply fix: X" code actions for the Lab marks touching `range`. + fn fix_actions(&self, uri: &Url, document: &OpenDocument, range: Range) -> Vec { + let text = document.text.as_str(); + let index = LineIndex::new(text); + let wanted = convert::byte_range_of(&index, range); + document + .lab + .iter() + .filter(|finding| { + let marked = finding.diagnostic.range.bytes(); + marked.start <= wanted.end && wanted.start <= marked.end + }) + .filter_map(|finding| { + let fix = finding.fix.as_ref()?; + Some(CodeAction { + title: fix.title.clone(), + kind: Some(FIX_KIND), + diagnostics: Some(vec![convert::diagnostic(&index, &finding.diagnostic)]), + edit: Some(self.workspace_edit( + uri, + document.version, + vec![convert::text_edit(&index, &fix.edit)], + )), + is_preferred: Some(true), + ..CodeAction::default() + }) + }) + .collect() + } + + /// `[i/n]` inlay hints for the KG terms of `text` inside `range`. + fn inlay_hints(&self, text: &str, range: Range) -> Vec { + let index = LineIndex::new(text); + let wanted = convert::byte_range_of(&index, range); + self.kg() + .engine + .synonym_positions(text) + .into_iter() + .filter(|hint| { + let term = hint.term.range.bytes(); + term.start <= wanted.end && wanted.start <= term.end + }) + .map(|hint| { + let concept = hint + .term + .description + .clone() + .unwrap_or_else(|| hint.term.nterm.clone()); + InlayHint { + position: convert::position(&index, hint.term.range.end.byte), + label: InlayHintLabel::String(hint.label()), + kind: None, + text_edits: None, + tooltip: Some(InlayHintTooltip::String(format!( + "Synonym {} of {} for {concept}", + hint.index, hint.count + ))), + padding_left: Some(true), + padding_right: None, + data: None, + } + }) + .collect() + } + + /// [`commands::ADD_ALTERNATIVE`]: the workspace edit that rewrites the + /// annotation block with the new alternative. + async fn add_alternative(&self, args: AddAlternativeArgs) -> Result> { + let document = self.checked_document(&args.uri, args.version).await?; + let text = document.text.as_str(); + let index = LineIndex::new(text); + let bytes = convert::byte_range_of(&index, args.range); + let range = TextRange::from_bytes(text, bytes.start, bytes.end.max(bytes.start)); + let added = add_alternative(text, range, &args.text, args.kind) + .map_err(|error| commands::invalid_params(error.to_string()))?; + let edit = self.workspace_edit( + &args.uri, + document.version, + vec![convert::text_edit(&index, &added.edit)], + ); + let value = serde_json::to_value(edit) + .map_err(|error| commands::invalid_params(error.to_string()))?; + Ok(Some(value)) + } + + /// The explicit apply variant for clients that only display command results. + /// Never holds a document lock while awaiting the client. A successful + /// acknowledgement must also match a change in the same open document. + async fn add_alternative_and_apply(&self, args: AddAlternativeArgs) -> Result> { + if !self.apply_edit.load(Ordering::Relaxed) || !self.versioned_edits.load(Ordering::Relaxed) + { + return Err(commands::invalid_params( + "add_and_apply requires workspace.applyEdit and versioned documentChanges", + )); + } + let document = self.checked_document(&args.uri, args.version).await?; + let index = LineIndex::new(&document.text); + let bytes = convert::byte_range_of(&index, args.range); + let range = TextRange::from_bytes(&document.text, bytes.start, bytes.end.max(bytes.start)); + let added = add_alternative(&document.text, range, &args.text, args.kind) + .map_err(|error| commands::invalid_params(error.to_string()))?; + let expected = + terraphim_lsp_core::apply_edits(&document.text, std::slice::from_ref(&added.edit)); + let edit = self.workspace_edit( + &args.uri, + document.version, + vec![convert::text_edit(&index, &added.edit)], + ); + // The client must also reject an edit overtaken after this check. + let current = self + .checked_document(&args.uri, Some(document.version)) + .await?; + if !Arc::ptr_eq(¤t.incarnation, &document.incarnation) { + return Err(alternative_apply_error( + true, + "document_reopened", + "Document closed or reopened before the edit request", + )); + } + let response = tokio::time::timeout( + std::time::Duration::from_secs(10), + self.client.apply_edit(edit), + ) + .await; + match response { + Ok(Ok(response)) if response.applied => {} + Ok(Ok(_)) => { + return Err(alternative_apply_error( + false, + "rejected", + "Client rejected the alternative edit", + )); + } + Ok(Err(_)) => { + return Err(alternative_apply_error( + false, + "request_failed", + "Alternative edit request failed; inspect the document before retrying", + )); + } + Err(_) => { + return Err(alternative_apply_error( + false, + "acknowledgement_timeout", + "Client did not acknowledge the edit; inspect the document before retrying", + )); + } + } + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + let changed = self.changed.notified(); + let current = self.documents.read().await.get(&args.uri).cloned(); + match current { + Some(current) if Arc::ptr_eq(¤t.incarnation, &document.incarnation) => { + if current.version > document.version { + if current.text == expected { + return Ok(Some(serde_json::json!({ + "applied": true, "uri": args.uri, "version": current.version, + }))); + } + return Err(alternative_apply_error( + true, + "document_changed", + "Client acknowledged the edit, but the document changed to different text", + )); + } + } + _ => { + return Err(alternative_apply_error( + true, + "document_reopened", + "Document closed or reopened while applying the alternative", + )); + } + } + if tokio::time::timeout_at(deadline, changed).await.is_err() { + return Err(alternative_apply_error( + false, + "accepted_not_observed", + "Client accepted the edit but did not report the expected document change; inspect the document before retrying", + )); + } + } + } + + /// "Ghost it" and "Revive" for the selection `range` (R-5.1, R-5.2): each + /// is a versioned workspace edit that rewrites only the annotation block. + /// A malformed block, or one with anchors that no longer match the body, + /// offers neither, as [`Self::add_alternative`] refuses to rewrite it. + fn ghost_actions(&self, uri: &Url, document: &OpenDocument, range: Range) -> Vec { + let text = document.text.as_str(); + let index = LineIndex::new(text); + let bytes = convert::byte_range_of(&index, range); + let selection = TextRange::from_bytes(text, bytes.start, bytes.end.max(bytes.start)); + let available = ghost_availability(text, selection); + let action = |title: &str, edited: std::result::Result| { + let edited = edited.ok()?; + Some(CodeAction { + title: title.to_string(), + kind: Some(GHOST_KIND), + edit: Some(self.workspace_edit( + uri, + document.version, + vec![convert::text_edit(&index, &edited.edit)], + )), + ..CodeAction::default() + }) + }; + let ghost = available + .can_ghost + .then(|| action("Ghost it", ghost_range(text, selection))) + .flatten(); + let revive = available + .can_revive + .then(|| action("Revive", revive_range(text, selection))) + .flatten(); + ghost.into_iter().chain(revive).collect() + } + + /// Re-anchor the annotation block of the saved document `uri` (R-9.2): + /// one versioned `workspace/applyEdit` rewrites only the block, and only + /// when a span or ghost moved or its stored context went stale. Saving + /// again changes nothing, so the edit cannot loop. The edit leaves the + /// buffer modified, so it is saved by the next save; clients that + /// cannot take `workspace/applyEdit` are left alone. + /// + /// Waits (briefly) for the client's change to arrive, so the Lab refresh + /// that follows runs on the text the client now has, not on text the + /// change is about to replace. + async fn reanchor_on_save(&self, uri: &Url) { + // Version the edit, but do not rely on clients rejecting an old + // version: some rebase against retained snapshots. Suppress duplicate + // automatic rewrites of the same snapshot before sending anything. + if !self.apply_edit.load(Ordering::Relaxed) || !self.versioned_edits.load(Ordering::Relaxed) + { + return; + } + // Snapshot, computation and reservation share one lock. No task can + // reserve from a snapshot of a previous revision or a closed document. + let (document, outcome, operation) = { + let mut documents = self.documents.write().await; + let Some(document) = documents.get_mut(uri) else { + return; + }; + if document.reanchoring.is_some() { + return; + } + let outcome = reanchor_block(&document.text); + let operation = self.cut_operations.fetch_add(1, Ordering::Relaxed); + if matches!(outcome, Ok(ReanchorOutcome::Rewritten(_))) { + document.reanchoring = Some(operation); + } + (document.clone(), outcome, operation) + }; + let edit = match outcome { + Ok(ReanchorOutcome::Rewritten(edit)) => edit, + Ok(ReanchorOutcome::Unresolved(count)) => { + self.client + .log_message( + MessageType::WARNING, + format!( + "terraphim-lsp: {count} span(s) or ghost(s) of {uri} no longer \ + match the body; the annotation block was not re-anchored" + ), + ) + .await; + return; + } + // Nothing moved, or a malformed block (already a diagnostic). + Ok(ReanchorOutcome::Unchanged) | Err(_) => return, + }; + let index = LineIndex::new(&document.text); + let edit = self.workspace_edit( + uri, + document.version, + vec![convert::text_edit(&index, &edit)], + ); + let response = self.client.apply_edit(edit).await; + // A negative acknowledgement permits retry. An old response cannot + // clear a newer operation (including one after close/reopen). + if !matches!(&response, Ok(response) if response.applied) { + let mut documents = self.documents.write().await; + if let Some(document) = documents.get_mut(uri) + && document.reanchoring == Some(operation) + { + document.reanchoring = None; + } + } + match response { + Ok(response) if response.applied => { + self.wait_for_change(uri, document.version).await; + } + Ok(response) => log::info!( + "terraphim_lsp: the client did not re-anchor {uri}: {:?}", + response.failure_reason + ), + Err(error) => log::debug!("terraphim_lsp: applyEdit failed: {error}"), + } + } + + /// Wait until the stored version of `uri` is past `version`, or two + /// seconds. A client sends `didChange` for an edit it applied to an open + /// document, so the wait ends as soon as that arrives; the limit only + /// bounds a client that never reports it. Changes to other documents + /// wake the wait but do not end it. + async fn wait_for_change(&self, uri: &Url, version: i32) { + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + // Created before the check, so a change in between is not missed. + let changed = self.changed.notified(); + let arrived = self + .documents + .read() + .await + .get(uri) + .is_none_or(|stored| stored.version > version); + if arrived || tokio::time::timeout_at(deadline, changed).await.is_err() { + return; + } + } + } + + /// [`commands::EXPORT`]: the clean Markdown of a document, written beside + /// it by the client when it can create files. + async fn export(&self, args: ExportArgs) -> Result> { + let document = self.checked_document(&args.uri, args.version).await?; + let markdown = export_clean(&document.text) + .map_err(|error| commands::invalid_params(error.to_string()))?; + let target = clean_export_uri(&args.uri); + // Resource operations travel in `documentChanges`. + let can_write = self.apply_edit.load(Ordering::Relaxed) + && self.versioned_edits.load(Ordering::Relaxed) + && self.create_file.load(Ordering::Relaxed); + let mut written = false; + if let Some(target) = target.as_ref().filter(|_| can_write) { + written = self + .write_export(&args.uri, &document, target, &markdown) + .await?; + } + Ok(Some(serde_json::json!({ + "markdown": markdown, + "uri": target, + "written": written, + }))) + } + + /// Share committed document synonym groups through the existing client edit path. + async fn export_graph(&self, args: ExportArgs) -> Result> { + let document = self.checked_document(&args.uri, args.version).await?; + let graph = export_knowledge_graph(&document.text) + .map_err(|error| commands::invalid_params(error.to_string()))?; + let json = serde_json::to_string_pretty(&graph) + .map_err(|error| commands::invalid_params(error.to_string()))?; + let target = args.uri.to_file_path().ok().and_then(|path| { + let stem = path.file_stem()?.to_str()?; + Url::from_file_path(path.with_file_name(format!("{stem}.knowledge-graph.json"))).ok() + }); + let can_write = self.apply_edit.load(Ordering::Relaxed) + && self.versioned_edits.load(Ordering::Relaxed) + && self.create_file.load(Ordering::Relaxed); + let mut written = false; + if let Some(target) = target.as_ref().filter(|_| can_write) { + written = self + .write_export(&args.uri, &document, target, &json) + .await?; + } + Ok(Some( + serde_json::json!({"graph": graph, "uri": target, "written": written}), + )) + } + + /// Refuse filesystem aliases even when their URI strings are different. + /// LSP does not report an authoritative dirty flag, so protect every other + /// open document. The exact output URI still uses the generated/disk-text + /// check below, allowing an unchanged output to be exported again. + async fn export_target_is_separate(&self, source: &Url, target: &Url) -> bool { + if source == target { + return false; + } + let Ok(target_path) = target.to_file_path() else { + return false; + }; + let mut protected: Vec = self + .documents + .read() + .await + .keys() + .filter(|uri| *uri != target) + .filter_map(|uri| uri.to_file_path().ok()) + .collect(); + if let Ok(path) = source.to_file_path() { + protected.push(path); + } + // same-file uses Unix device/inode and Windows volume/file identity; + // canonical paths alone cannot detect hard links. Its file opens run + // off the async executor, and all inspection errors fail closed. + let separate = tokio::task::spawn_blocking(move || -> std::io::Result { + let target_handle = match same_file::Handle::from_path(&target_path) { + Ok(handle) => handle, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + // A genuinely new path is safe to create without overwrite. + // A dangling symlink is not a new independent output file. + return match std::fs::symlink_metadata(&target_path) { + Err(absent) if absent.kind() == std::io::ErrorKind::NotFound => Ok(true), + _ => Err(error), + }; + } + Err(error) => return Err(error), + }; + for path in protected { + match same_file::Handle::from_path(path) { + Ok(handle) if handle == target_handle => return Ok(false), + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + } + Ok(true) + }) + .await; + match separate { + Ok(Ok(value)) => value, + error => { + log::warn!("terraphim_lsp: cannot verify export target identity: {error:?}"); + false + } + } + } + + /// Create (or replace) `target` with `markdown` through the client, then + /// show it when the client can. False when the client did not apply it; + /// ContentModified when destination inspection outlives the source snapshot. + async fn write_export( + &self, + source: &Url, + snapshot: &OpenDocument, + target: &Url, + markdown: &str, + ) -> Result { + if !self.export_target_is_separate(source, target).await { + self.client.show_message(MessageType::WARNING, + "terraphim-lsp: choose an independent export file; the destination may alias the source or another open document.").await; + return Ok(false); + } + let open = self.documents.read().await.get(target).cloned(); + let disk = if let Ok(path) = target.to_file_path() { + match tokio::fs::read_to_string(path).await { + Ok(text) => Some(text), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => { + log::warn!("terraphim_lsp: cannot inspect export target: {error}"); + return Ok(false); + } + } + } else { + return Ok(false); + }; + if let Some(document) = open.as_ref() { + let generated = self.exported.read().await.get(target).cloned(); + if generated.as_deref() != Some(&document.text) + && disk.as_deref() != Some(&document.text) + { + self.client.show_message(MessageType::WARNING, + "terraphim-lsp: the export buffer has unconfirmed changes; save or close it before exporting again.").await; + return Ok(false); + } + } + let previous = open + .as_ref() + .map(|d| d.text.as_str()) + .or(disk.as_deref()) + .unwrap_or(""); + let end = convert::position(&LineIndex::new(previous), previous.len()); + let mut operations = Vec::new(); + if open.is_none() && disk.is_none() { + operations.push(DocumentChangeOperation::Op(ResourceOp::Create( + CreateFile { + uri: target.clone(), + options: Some(CreateFileOptions { + overwrite: Some(false), + ignore_if_exists: None, + }), + annotation_id: None, + }, + ))); + } + operations.push(DocumentChangeOperation::Edit(TextDocumentEdit { + text_document: OptionalVersionedTextDocumentIdentifier { + uri: target.clone(), + version: open.as_ref().map(|d| d.version), + }, + edits: vec![OneOf::Left(TextEdit { + range: Range { + start: Position::default(), + end, + }, + new_text: markdown.to_string(), + })], + })); + let edit = WorkspaceEdit { + document_changes: Some(DocumentChanges::Operations(operations)), + ..WorkspaceEdit::default() + }; + // Refresh protection after inspecting output buffers and disk contents; + // neither a newly opened alias nor a changed link may cross that work. + if !self.export_target_is_separate(source, target).await { + return Ok(false); + } + // Filesystem checks above yield to didChange/close/open notifications. + // Check both version and text immediately before dispatch; reopening a + // different document at the same URI can restart its version sequence. + if !self + .documents + .read() + .await + .get(source) + .is_some_and(|current| { + current.version == snapshot.version && current.text == snapshot.text + }) + { + return Err(tower_lsp::jsonrpc::Error { + code: tower_lsp::jsonrpc::ErrorCode::ContentModified, + message: "Source document changed or closed while preparing export".into(), + data: None, + }); + } + let applied = match self.client.apply_edit(edit).await { + Ok(response) => response.applied, + Err(error) => { + log::debug!("terraphim_lsp: applyEdit failed: {error}"); + false + } + }; + if applied { + self.exported + .write() + .await + .insert(target.clone(), markdown.to_string()); + } + if applied && self.show_document.load(Ordering::Relaxed) { + let shown = self + .client + .show_document(ShowDocumentParams { + uri: target.clone(), + external: Some(false), + take_focus: Some(true), + selection: None, + }) + .await; + if let Err(error) = shown { + log::debug!("terraphim_lsp: showDocument failed: {error}"); + } + } + Ok(applied) + } + + /// "Stash in Overflow" for the selection `range` and "Pull back: + /// «excerpt»" for the first paragraphs of the overflow at the cursor + /// (R-6.3, R-6.4): each is a versioned workspace edit that moves the text + /// and rewrites the annotation block with it. A malformed block, anchors + /// that no longer match the body, and (for a stash) a selection touching + /// a span or (for a pull back) a cursor inside one offer nothing, as + /// the edit would be refused. + fn overflow_actions( + &self, + uri: &Url, + document: &OpenDocument, + range: Range, + ) -> Vec { + let text = document.text.as_str(); + let index = LineIndex::new(text); + let bytes = convert::byte_range_of(&index, range); + let action = |title: String, edited: std::result::Result| { + let edited = edited.ok()?; + let edits = edited + .edits + .iter() + .map(|edit| convert::text_edit(&index, edit)) + .collect(); + Some(CodeAction { + title, + kind: Some(OVERFLOW_KIND), + edit: Some(self.workspace_edit(uri, document.version, edits)), + ..CodeAction::default() + }) + }; + let stash = (bytes.start < bytes.end) + .then(|| { + let selection = TextRange::from_bytes(text, bytes.start, bytes.end); + action( + "Stash in Overflow".to_string(), + stash_range(text, selection), + ) + }) + .flatten(); + let pulls = overflow_paragraphs(text) + .into_iter() + .take(PULL_BACK_LIMIT) + .filter_map(|paragraph| { + action( + format!( + "Pull back: «{}»", + excerpt( + ¶graph + .text + .split_whitespace() + .collect::>() + .join(" ") + ) + ), + pull_back(text, paragraph.index, bytes.start), + ) + }); + stash.into_iter().chain(pulls).collect() + } + + /// The command-only "Export clean Markdown" (R-9.3), running + /// `terraphim.export`. Not offered when the export would be refused (a + /// malformed block, or a ghost that can no longer be found), or would + /// change nothing. The command carries no version: Zed runs a cached + /// action after the document changed, and an export reads the document + /// as it is when it runs (it edits nothing, so there is nothing to + /// protect). + fn export_action(&self, uri: &Url, document: &OpenDocument) -> Option { + // Nothing to export from a document with no ghosts, overflow or + // block: the text would come out unchanged. + let clean = export_clean(&document.text).ok()?; + (clean != document.text).then_some(())?; + let title = "Export clean Markdown".to_string(); + Some(CodeAction { + title: title.clone(), + kind: Some(EXPORT_KIND), + command: Some(Command { + title, + command: commands::EXPORT.to_string(), + arguments: Some(vec![serde_json::json!({"uri": uri})]), + }), + ..CodeAction::default() + }) + } + + fn export_graph_action(&self, uri: &Url, document: &OpenDocument) -> Option { + let graph = export_knowledge_graph(&document.text).ok()?; + if graph.is_empty() { + return None; + } + let title = "Export document knowledge graph".to_string(); + Some(CodeAction { + title: title.clone(), + kind: Some(EXPORT_KIND), + command: Some(Command { + title, + command: commands::EXPORT_KNOWLEDGE_GRAPH.to_string(), + arguments: Some(vec![ + serde_json::json!({"uri": uri, "version": document.version}), + ]), + }), + ..CodeAction::default() + }) + } + + /// [`commands::LAB_MARK`]: run one Lab action and publish its marks. + async fn lab_mark(&self, args: LabMarkArgs) -> Result> { + let action = args.action; + let document = self + .update_lab(&args.uri, args.version, |document| { + if let Err(at) = document.lab_actions.binary_search(&action) { + document.lab_actions.insert(at, action); + } + }) + .await?; + let marks = document + .lab + .iter() + .filter(|finding| finding.action == action) + .count(); + Ok(Some( + serde_json::json!({ "action": action, "marks": marks }), + )) + } + + /// [`commands::LAB_CLEAR`]: drop the requested Lab actions and every + /// Lab mark until the configured actions next run. + async fn lab_clear(&self, args: DocumentArgs) -> Result> { + self.clear_overlay(&args.uri, |document| { + document.lab_actions.clear(); + document.lab.clear(); + }) + .await + } + + /// The command-only Lab actions: one "Lab: