diff --git a/.envrc b/.envrc index 00cc6f20e..8643617f0 100644 --- a/.envrc +++ b/.envrc @@ -3,3 +3,4 @@ watch_file flake.lock # try to use flakes, if it fails use normal nix (ie. shell.nix) use flake || use nix +eval "$shellHook" \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 41b00230f..000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: '' -labels: C-bug -assignees: '' - ---- - - - -### Reproduction steps - - - -### Environment - -- Platform: -- Terminal emulator: -- Helix version: - -
~/.cache/helix/helix.log - -``` -please provide a copy of `~/.cache/helix/helix.log` here if possible, you may need to redact some of the lines -``` - -
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml new file mode 100644 index 000000000..c67deb690 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -0,0 +1,67 @@ +name: Bug Report +description: Create a report to help us improve +labels: C-bug +body: + - type: markdown + attributes: + value: Thank you for filing a bug report! 🐛 + - type: textarea + id: problem + attributes: + label: Summary + description: > + Please provide a short summary of the bug, along with any information + you feel relevant to replicate the bug. + validations: + required: true + - type: textarea + id: reproduction-steps + attributes: + label: Reproduction Steps + value: | + + + I tried this: + + 1. `hx` + + I expected this to happen: + + Instead, this happened: + - type: textarea + id: helix-log + attributes: + label: Helix log + description: See `hx -h` for log file path + value: | +
~/.cache/helix/helix.log + + ``` + please provide a copy of `~/.cache/helix/helix.log` here if possible, you may need to redact some of the lines + ``` + +
+ - type: input + id: platform + attributes: + label: Platform + placeholder: Linux / macOS / Windows + validations: + required: true + - type: input + id: terminal-emulator + attributes: + label: Terminal Emulator + placeholder: wezterm 20220101-133340-7edc5b5a + validations: + required: true + - type: input + id: helix-version + attributes: + label: Helix Version + description: > + Helix version (`hx -V` if using a release, `git describe` if building + from master) + placeholder: "helix 0.6.0 (c0dbd6dc)" + validations: + required: true diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 65c2f9495..69d88f836 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,9 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout sources - uses: actions/checkout@v2 - with: - submodules: true + uses: actions/checkout@v3 - name: Install stable toolchain uses: actions-rs/toolchain@v1 @@ -25,22 +23,25 @@ jobs: override: true - name: Cache cargo registry - uses: actions/cache@v2.1.7 + uses: actions/cache@v3 with: path: ~/.cargo/registry key: ${{ runner.os }}-v2-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-v2-cargo-registry- - name: Cache cargo index - uses: actions/cache@v2.1.7 + uses: actions/cache@v3 with: path: ~/.cargo/git key: ${{ runner.os }}-v2-cargo-index-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-v2-cargo-index- - name: Cache cargo target dir - uses: actions/cache@v2.1.7 + uses: actions/cache@v3 with: path: target key: ${{ runner.os }}-v2-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-v2-cargo-build-target- - name: Run cargo check uses: actions-rs/cargo@v1 @@ -52,9 +53,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: Checkout sources - uses: actions/checkout@v2 - with: - submodules: true + uses: actions/checkout@v3 - name: Install stable toolchain uses: actions-rs/toolchain@v1 @@ -64,27 +63,41 @@ jobs: override: true - name: Cache cargo registry - uses: actions/cache@v2.1.7 + uses: actions/cache@v3 with: path: ~/.cargo/registry key: ${{ runner.os }}-v2-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-v2-cargo-registry- - name: Cache cargo index - uses: actions/cache@v2.1.7 + uses: actions/cache@v3 with: path: ~/.cargo/git key: ${{ runner.os }}-v2-cargo-index-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-v2-cargo-index- - name: Cache cargo target dir - uses: actions/cache@v2.1.7 + uses: actions/cache@v3 with: path: target key: ${{ runner.os }}-v2-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-v2-cargo-build-target- + + - name: Copy minimal languages config + run: cp .github/workflows/languages.toml ./languages.toml + + - name: Cache test tree-sitter grammar + uses: actions/cache@v3 + with: + path: runtime/grammars + key: ${{ runner.os }}-v2-tree-sitter-grammars-${{ hashFiles('languages.toml') }} + restore-keys: ${{ runner.os }}-v2-tree-sitter-grammars- - name: Run cargo test uses: actions-rs/cargo@v1 with: command: test + args: --workspace strategy: matrix: @@ -96,9 +109,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout sources - uses: actions/checkout@v2 - with: - submodules: true + uses: actions/checkout@v3 - name: Install stable toolchain uses: actions-rs/toolchain@v1 @@ -109,22 +120,25 @@ jobs: components: rustfmt, clippy - name: Cache cargo registry - uses: actions/cache@v2.1.7 + uses: actions/cache@v3 with: path: ~/.cargo/registry key: ${{ runner.os }}-v2-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-v2-cargo-registry- - name: Cache cargo index - uses: actions/cache@v2.1.7 + uses: actions/cache@v3 with: path: ~/.cargo/git key: ${{ runner.os }}-v2-cargo-index-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-v2-cargo-index- - name: Cache cargo target dir - uses: actions/cache@v2.1.7 + uses: actions/cache@v3 with: path: target key: ${{ runner.os }}-v2-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-v2-cargo-build-target- - name: Run cargo fmt uses: actions-rs/cargo@v1 @@ -143,9 +157,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout sources - uses: actions/checkout@v2 - with: - submodules: true + uses: actions/checkout@v3 - name: Install stable toolchain uses: actions-rs/toolchain@v1 @@ -155,22 +167,25 @@ jobs: override: true - name: Cache cargo registry - uses: actions/cache@v2.1.6 + uses: actions/cache@v3 with: path: ~/.cargo/registry key: ${{ runner.os }}-v2-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-v2-cargo-registry- - name: Cache cargo index - uses: actions/cache@v2.1.6 + uses: actions/cache@v3 with: path: ~/.cargo/git key: ${{ runner.os }}-v2-cargo-index-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-v2-cargo-index- - name: Cache cargo target dir - uses: actions/cache@v2.1.6 + uses: actions/cache@v3 with: path: target key: ${{ runner.os }}-v2-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-v2-cargo-build-target- - name: Generate docs uses: actions-rs/cargo@v1 diff --git a/.github/workflows/cachix.yml b/.github/workflows/cachix.yml new file mode 100644 index 000000000..f820bc746 --- /dev/null +++ b/.github/workflows/cachix.yml @@ -0,0 +1,26 @@ +# Publish the Nix flake outputs to Cachix +name: Cachix +on: + push: + branches: + - master + +jobs: + publish: + name: Publish Flake + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@v3 + + - name: Install nix + uses: cachix/install-nix-action@v16 + + - name: Authenticate with Cachix + uses: cachix/cachix-action@v10 + with: + name: helix + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + + - name: Build nix flake + run: nix build diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index 970cf82f7..223f8450f 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -4,12 +4,14 @@ on: push: branches: - master + tags: + - '*' jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Setup mdBook uses: peaceiris/actions-mdbook@v1 @@ -18,10 +20,22 @@ jobs: # mdbook-version: '0.4.8' - run: mdbook build book + + - name: Set output directory + run: | + OUTDIR=$(basename ${{ github.ref }}) + echo "OUTDIR=$OUTDIR" >> $GITHUB_ENV - name: Deploy uses: peaceiris/actions-gh-pages@v3 - if: github.ref == 'refs/heads/master' + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./book/book + destination_dir: ./${{ env.OUTDIR }} + + - name: Deploy stable + uses: peaceiris/actions-gh-pages@v3 + if: startswith(github.ref, 'refs/tags/') with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./book/book diff --git a/.github/workflows/languages.toml b/.github/workflows/languages.toml new file mode 100644 index 000000000..18cf71cf5 --- /dev/null +++ b/.github/workflows/languages.toml @@ -0,0 +1,26 @@ +# This languages.toml is used for testing in CI. + +[[language]] +name = "rust" +scope = "source.rust" +injection-regex = "rust" +file-types = ["rs"] +comment-token = "//" +roots = ["Cargo.toml", "Cargo.lock"] +indent = { tab-width = 4, unit = " " } + +[[grammar]] +name = "rust" +source = { git = "https://github.com/tree-sitter/tree-sitter-rust", rev = "a360da0a29a19c281d08295a35ecd0544d2da211" } + +[[language]] +name = "nix" +scope = "source.nix" +injection-regex = "nix" +file-types = ["nix"] +shebangs = [] +roots = [] +comment-token = "#" + +# A grammar entry is not necessary for this language - it is only used for +# testing TOML merging behavior. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7b0b7ee24..eb36c7867 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,32 +1,81 @@ name: Release on: - # schedule: - # - cron: '0 0 * * *' # midnight UTC - push: tags: - - 'v[0-9]+.[0-9]+.[0-9]+' - ## - release + - '[0-9]+.[0-9]+' + - '[0-9]+.[0-9]+.[0-9]+' jobs: + fetch-grammars: + name: Fetch Grammars + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@v3 + + - name: Install stable toolchain + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + override: true + + - name: Cache cargo registry + uses: actions/cache@v3 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-v2-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-v2-cargo-registry- + + - name: Cache cargo index + uses: actions/cache@v3 + with: + path: ~/.cargo/git + key: ${{ runner.os }}-v2-cargo-index-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-v2-cargo-index- + + - name: Cache cargo target dir + uses: actions/cache@v3 + with: + path: target + key: ${{ runner.os }}-v2-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-v2-cargo-build-target- + + - name: Fetch tree-sitter grammars + uses: actions-rs/cargo@v1 + env: + HELIX_DISABLE_AUTO_GRAMMAR_BUILD: yes + with: + command: run + args: -- --grammar fetch + + - name: Bundle grammars + run: tar cJf grammars.tar.xz -C runtime/grammars/sources . + + - uses: actions/upload-artifact@v3 + with: + name: grammars + path: grammars.tar.xz + dist: name: Dist + needs: [fetch-grammars] runs-on: ${{ matrix.os }} strategy: fail-fast: false # don't fail other jobs if one fails matrix: - build: [x86_64-linux, aarch64-linux, x86_64-macos, x86_64-windows] #, x86_64-win-gnu, win32-msvc + build: [x86_64-linux, x86_64-macos, x86_64-windows] #, x86_64-win-gnu, win32-msvc include: - build: x86_64-linux os: ubuntu-20.04 rust: stable target: x86_64-unknown-linux-gnu cross: false - - build: aarch64-linux - os: ubuntu-20.04 - rust: stable - target: aarch64-unknown-linux-gnu - cross: true + # - build: aarch64-linux + # os: ubuntu-20.04 + # rust: stable + # target: aarch64-unknown-linux-gnu + # cross: true - build: x86_64-macos os: macos-latest rust: stable @@ -52,9 +101,16 @@ jobs: steps: - name: Checkout sources - uses: actions/checkout@v2 - with: - submodules: true + uses: actions/checkout@v3 + + - name: Download grammars + uses: actions/download-artifact@v2 + + - name: Move grammars under runtime + if: "!startsWith(matrix.os, 'windows')" + run: | + mkdir -p runtime/grammars/sources + tar xJf grammars/grammars.tar.xz -C runtime/grammars/sources - name: Install ${{ matrix.rust }} toolchain uses: actions-rs/toolchain@v1 @@ -69,7 +125,7 @@ jobs: with: use-cross: ${{ matrix.cross }} command: test - args: --release --locked --target ${{ matrix.target }} + args: --release --locked --target ${{ matrix.target }} --workspace - name: Build release binary uses: actions-rs/cargo@v1 @@ -100,9 +156,10 @@ jobs: else cp "target/${{ matrix.target }}/release/hx" "dist/" fi + rm -rf runtime/grammars/sources cp -r runtime dist - - uses: actions/upload-artifact@v2.3.1 + - uses: actions/upload-artifact@v3 with: name: bins-${{ matrix.build }} path: dist @@ -113,20 +170,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout sources - uses: actions/checkout@v2 - with: - submodules: false + uses: actions/checkout@v3 - uses: actions/download-artifact@v2 - # with: - # path: dist - # - run: ls -al ./dist - - run: ls -al bins-* - name: Calculate tag name run: | name=dev - if [[ $GITHUB_REF == refs/tags/v* ]]; then + if [[ $GITHUB_REF == refs/tags/* ]]; then name=${GITHUB_REF:10} fi echo ::set-output name=val::$name @@ -138,8 +189,13 @@ jobs: run: | set -ex - rm -rf tmp - mkdir tmp + source="$(pwd)" + mkdir -p runtime/grammars/sources + tar xJf grammars/grammars.tar.xz -C runtime/grammars/sources + rm -rf grammars + + cd "$(mktemp -d)" + mv $source/bins-* . mkdir dist for dir in bins-* ; do @@ -148,19 +204,22 @@ jobs: exe=".exe" fi pkgname=helix-$TAG-$platform - mkdir tmp/$pkgname - cp LICENSE README.md tmp/$pkgname - mv bins-$platform/runtime tmp/$pkgname/ - mv bins-$platform/hx$exe tmp/$pkgname - chmod +x tmp/$pkgname/hx$exe + mkdir $pkgname + cp $source/LICENSE $source/README.md $pkgname + mv bins-$platform/runtime $pkgname/ + mv bins-$platform/hx$exe $pkgname + chmod +x $pkgname/hx$exe if [ "$exe" = "" ]; then - tar cJf dist/$pkgname.tar.xz -C tmp $pkgname + tar cJf dist/$pkgname.tar.xz $pkgname else - (cd tmp && 7z a -r ../dist/$pkgname.zip $pkgname) + 7z a -r dist/$pkgname.zip $pkgname fi done + tar cJf dist/helix-$TAG-source.tar.xz -C $source . + mv dist $source/ + - name: Upload binaries to release uses: svenstaro/upload-release-action@v2 with: diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 5b6609a80..000000000 --- a/.gitmodules +++ /dev/null @@ -1,231 +0,0 @@ -[submodule "helix-syntax/languages/tree-sitter-cpp"] - path = helix-syntax/languages/tree-sitter-cpp - url = https://github.com/tree-sitter/tree-sitter-cpp - shallow = true -[submodule "helix-syntax/languages/tree-sitter-javascript"] - path = helix-syntax/languages/tree-sitter-javascript - url = https://github.com/tree-sitter/tree-sitter-javascript - shallow = true -[submodule "helix-syntax/languages/tree-sitter-julia"] - path = helix-syntax/languages/tree-sitter-julia - url = https://github.com/tree-sitter/tree-sitter-julia - shallow = true -[submodule "helix-syntax/languages/tree-sitter-python"] - path = helix-syntax/languages/tree-sitter-python - url = https://github.com/tree-sitter/tree-sitter-python - shallow = true -[submodule "helix-syntax/languages/tree-sitter-typescript"] - path = helix-syntax/languages/tree-sitter-typescript - url = https://github.com/tree-sitter/tree-sitter-typescript - shallow = true -[submodule "helix-syntax/languages/tree-sitter-agda"] - path = helix-syntax/languages/tree-sitter-agda - url = https://github.com/tree-sitter/tree-sitter-agda - shallow = true -[submodule "helix-syntax/languages/tree-sitter-go"] - path = helix-syntax/languages/tree-sitter-go - url = https://github.com/tree-sitter/tree-sitter-go - shallow = true -[submodule "helix-syntax/languages/tree-sitter-ruby"] - path = helix-syntax/languages/tree-sitter-ruby - url = https://github.com/tree-sitter/tree-sitter-ruby - shallow = true -[submodule "helix-syntax/languages/tree-sitter-java"] - path = helix-syntax/languages/tree-sitter-java - url = https://github.com/tree-sitter/tree-sitter-java - shallow = true -[submodule "helix-syntax/languages/tree-sitter-php"] - path = helix-syntax/languages/tree-sitter-php - url = https://github.com/tree-sitter/tree-sitter-php - shallow = true -[submodule "helix-syntax/languages/tree-sitter-html"] - path = helix-syntax/languages/tree-sitter-html - url = https://github.com/tree-sitter/tree-sitter-html - shallow = true -[submodule "helix-syntax/languages/tree-sitter-scala"] - path = helix-syntax/languages/tree-sitter-scala - url = https://github.com/tree-sitter/tree-sitter-scala - shallow = true -[submodule "helix-syntax/languages/tree-sitter-bash"] - path = helix-syntax/languages/tree-sitter-bash - url = https://github.com/tree-sitter/tree-sitter-bash - shallow = true -[submodule "helix-syntax/languages/tree-sitter-rust"] - path = helix-syntax/languages/tree-sitter-rust - url = https://github.com/tree-sitter/tree-sitter-rust - shallow = true -[submodule "helix-syntax/languages/tree-sitter-json"] - path = helix-syntax/languages/tree-sitter-json - url = https://github.com/tree-sitter/tree-sitter-json - shallow = true -[submodule "helix-syntax/languages/tree-sitter-css"] - path = helix-syntax/languages/tree-sitter-css - url = https://github.com/tree-sitter/tree-sitter-css - shallow = true -[submodule "helix-syntax/languages/tree-sitter-c-sharp"] - path = helix-syntax/languages/tree-sitter-c-sharp - url = https://github.com/tree-sitter/tree-sitter-c-sharp - shallow = true -[submodule "helix-syntax/languages/tree-sitter-c"] - path = helix-syntax/languages/tree-sitter-c - url = https://github.com/tree-sitter/tree-sitter-c - shallow = true -[submodule "helix-syntax/languages/tree-sitter-haskell"] - path = helix-syntax/languages/tree-sitter-haskell - url = https://github.com/tree-sitter/tree-sitter-haskell - shallow = true -[submodule "helix-syntax/languages/tree-sitter-swift"] - path = helix-syntax/languages/tree-sitter-swift - url = https://github.com/tree-sitter/tree-sitter-swift - shallow = true -[submodule "helix-syntax/languages/tree-sitter-toml"] - path = helix-syntax/languages/tree-sitter-toml - url = https://github.com/ikatyang/tree-sitter-toml - shallow = true -[submodule "helix-syntax/languages/tree-sitter-elixir"] - path = helix-syntax/languages/tree-sitter-elixir - url = https://github.com/elixir-lang/tree-sitter-elixir - shallow = true -[submodule "helix-syntax/languages/tree-sitter-nix"] - path = helix-syntax/languages/tree-sitter-nix - url = https://github.com/cstrahan/tree-sitter-nix - shallow = true -[submodule "helix-syntax/languages/tree-sitter-latex"] - path = helix-syntax/languages/tree-sitter-latex - url = https://github.com/latex-lsp/tree-sitter-latex - shallow = true -[submodule "helix-syntax/languages/tree-sitter-ledger"] - path = helix-syntax/languages/tree-sitter-ledger - url = https://github.com/cbarrete/tree-sitter-ledger - shallow = true -[submodule "helix-syntax/languages/tree-sitter-protobuf"] - path = helix-syntax/languages/tree-sitter-protobuf - url = https://github.com/yusdacra/tree-sitter-protobuf.git - shallow = true -[submodule "helix-syntax/languages/tree-sitter-ocaml"] - path = helix-syntax/languages/tree-sitter-ocaml - url = https://github.com/tree-sitter/tree-sitter-ocaml - shallow = true -[submodule "helix-syntax/languages/tree-sitter-lua"] - path = helix-syntax/languages/tree-sitter-lua - url = https://github.com/nvim-treesitter/tree-sitter-lua - shallow = true -[submodule "helix-syntax/languages/tree-sitter-yaml"] - path = helix-syntax/languages/tree-sitter-yaml - url = https://github.com/ikatyang/tree-sitter-yaml - shallow = true -[submodule "helix-syntax/languages/tree-sitter-zig"] - path = helix-syntax/languages/tree-sitter-zig - url = https://github.com/maxxnino/tree-sitter-zig - shallow = true -[submodule "helix-syntax/languages/tree-sitter-svelte"] - path = helix-syntax/languages/tree-sitter-svelte - url = https://github.com/Himujjal/tree-sitter-svelte - shallow = true -[submodule "helix-syntax/languages/tree-sitter-vue"] - path = helix-syntax/languages/tree-sitter-vue - url = https://github.com/ikatyang/tree-sitter-vue - shallow = true -[submodule "helix-syntax/languages/tree-sitter-tsq"] - path = helix-syntax/languages/tree-sitter-tsq - url = https://github.com/tree-sitter/tree-sitter-tsq - shallow = true -[submodule "helix-syntax/languages/tree-sitter-cmake"] - path = helix-syntax/languages/tree-sitter-cmake - url = https://github.com/uyha/tree-sitter-cmake - shallow = true -[submodule "helix-syntax/languages/tree-sitter-glsl"] - path = helix-syntax/languages/tree-sitter-glsl - url = https://github.com/theHamsta/tree-sitter-glsl.git - shallow = true -[submodule "helix-syntax/languages/tree-sitter-perl"] - path = helix-syntax/languages/tree-sitter-perl - url = https://github.com/ganezdragon/tree-sitter-perl - shallow = true -[submodule "helix-syntax/languages/tree-sitter-comment"] - path = helix-syntax/languages/tree-sitter-comment - url = https://github.com/stsewd/tree-sitter-comment - shallow = true -[submodule "helix-syntax/languages/tree-sitter-wgsl"] - path = helix-syntax/languages/tree-sitter-wgsl - url = https://github.com/szebniok/tree-sitter-wgsl - shallow = true -[submodule "helix-syntax/languages/tree-sitter-llvm"] - path = helix-syntax/languages/tree-sitter-llvm - url = https://github.com/benwilliamgraham/tree-sitter-llvm - shallow = true -[submodule "helix-syntax/languages/tree-sitter-markdown"] - path = helix-syntax/languages/tree-sitter-markdown - url = https://github.com/MDeiml/tree-sitter-markdown - shallow = true -[submodule "helix-syntax/languages/tree-sitter-dart"] - path = helix-syntax/languages/tree-sitter-dart - url = https://github.com/UserNobody14/tree-sitter-dart.git - shallow = true -[submodule "helix-syntax/languages/tree-sitter-dockerfile"] - path = helix-syntax/languages/tree-sitter-dockerfile - url = https://github.com/camdencheek/tree-sitter-dockerfile.git - shallow = true -[submodule "helix-syntax/languages/tree-sitter-fish"] - path = helix-syntax/languages/tree-sitter-fish - url = https://github.com/ram02z/tree-sitter-fish - shallow = true -[submodule "helix-syntax/languages/tree-sitter-git-commit"] - path = helix-syntax/languages/tree-sitter-git-commit - url = https://github.com/the-mikedavis/tree-sitter-git-commit.git - shallow = true -[submodule "helix-syntax/languages/tree-sitter-llvm-mir"] - path = helix-syntax/languages/tree-sitter-llvm-mir - url = https://github.com/Flakebi/tree-sitter-llvm-mir.git - shallow = true -[submodule "helix-syntax/languages/tree-sitter-git-diff"] - path = helix-syntax/languages/tree-sitter-git-diff - url = https://github.com/the-mikedavis/tree-sitter-git-diff.git - shallow = true -[submodule "helix-syntax/languages/tree-sitter-tablegen"] - path = helix-syntax/languages/tree-sitter-tablegen - url = https://github.com/Flakebi/tree-sitter-tablegen - shallow = true -[submodule "helix-syntax/languages/tree-sitter-git-rebase"] - path = helix-syntax/languages/tree-sitter-git-rebase - url = https://github.com/the-mikedavis/tree-sitter-git-rebase.git - shallow = true -[submodule "helix-syntax/languages/tree-sitter-lean"] - path = helix-syntax/languages/tree-sitter-lean - url = https://github.com/Julian/tree-sitter-lean - shallow = true -[submodule "helix-syntax/languages/tree-sitter-regex"] - path = helix-syntax/languages/tree-sitter-regex - url = https://github.com/tree-sitter/tree-sitter-regex.git - shallow = true -[submodule "helix-syntax/languages/tree-sitter-make"] - path = helix-syntax/languages/tree-sitter-make - url = https://github.com/alemuller/tree-sitter-make - shallow = true -[submodule "helix-syntax/languages/tree-sitter-git-config"] - path = helix-syntax/languages/tree-sitter-git-config - url = https://github.com/the-mikedavis/tree-sitter-git-config.git - shallow = true -[submodule "helix-syntax/languages/tree-sitter-graphql"] - path = helix-syntax/languages/tree-sitter-graphql - url = https://github.com/bkegley/tree-sitter-graphql - shallow = true -[submodule "helix-syntax/languages/tree-sitter-elm"] - path = helix-syntax/languages/tree-sitter-elm - url = https://github.com/elm-tooling/tree-sitter-elm - shallow = true -[submodule "helix-syntax/languages/tree-sitter-iex"] - path = helix-syntax/languages/tree-sitter-iex - url = https://github.com/elixir-lang/tree-sitter-iex - shallow = true -[submodule "helix-syntax/languages/tree-sitter-twig"] - path = helix-syntax/languages/tree-sitter-twig - url = https://github.com/eirabben/tree-sitter-twig.git - shallow = true -[submodule "helix-syntax/languages/tree-sitter-rescript"] - path = helix-syntax/languages/tree-sitter-rescript - url = https://github.com/jaredramirez/tree-sitter-rescript - shallow = true -[submodule "helix-syntax/languages/tree-sitter-erlang"] - path = helix-syntax/languages/tree-sitter-erlang - url = https://github.com/the-mikedavis/tree-sitter-erlang diff --git a/CHANGELOG.md b/CHANGELOG.md index 389279912..56a8b2572 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,165 @@ +# 22.03 (2022-03-28) + +A big shout out to all the contributors! We had 51 contributors in this release. + +This release is particularly large and featureful. Check out some of the +highlights in the [news section](https://helix-editor.com/news/release-22-03-highlights/). + +As usual, the following is a summary of each of the changes since the last release. +For the full log, check out the [git log](https://github.com/helix-editor/helix/compare/v0.6.0..22.03). + +Breaking changes: + +- LSP config now lives under `editor.lsp` ([#1868](https://github.com/helix-editor/helix/pull/1868)) +- Expand-selection was moved from `]o` to `Alt-h` ([#1495](https://github.com/helix-editor/helix/pull/1495)) + +Features: + +- Experimental Debug Adapter Protocol (DAP) support ([#574](https://github.com/helix-editor/helix/pull/574)) +- Primary cursor shape may now be customized per mode ([#1154](https://github.com/helix-editor/helix/pull/1154)) +- Overhaul incremental highlights and enable combined injections ([`6728344..4080341`](https://github.com/helix-editor/helix/compare/6728344..4080341)) +- Allow specifying file start position ([#445](https://github.com/helix-editor/helix/pull/445), [#1676](https://github.com/helix-editor/helix/pull/1676)) +- Dynamic line numbers ([#1522](https://github.com/helix-editor/helix/pull/1522)) +- Show an info box with the contents of registers ([#980](https://github.com/helix-editor/helix/pull/980)) +- Wrap-around behavior during search is now configurable ([#1516](https://github.com/helix-editor/helix/pull/1516)) +- Tree-sitter textobjects motions for classes, functions, and parameters ([#1619](https://github.com/helix-editor/helix/pull/1619), [#1708](https://github.com/helix-editor/helix/pull/1708), [#1805](https://github.com/helix-editor/helix/pull/1805)) +- Command palette: a picker for available commands ([#1400](https://github.com/helix-editor/helix/pull/1400)) +- LSP `workspace/configuration` and `workspace/didChangeConfiguration` support ([#1684](https://github.com/helix-editor/helix/pull/1684)) +- `hx --health [LANG]` command ([#1669](https://github.com/helix-editor/helix/pull/1669)) +- Refactor of the tree-sitter grammar system ([#1659](https://github.com/helix-editor/helix/pull/1659)) + - All submodules have been removed + - New `hx --grammar {fetch|build}` flags for fetching and building tree-sitter grammars + - A custom grammar selection may now be declared with the `use-grammars` key in `languages.toml` + +Commands: + +- `:cquit!` - quit forcefully with a non-zero exit-code ([#1414](https://github.com/helix-editor/helix/pull/1414)) +- `shrink_selection` - shrink the selection to a child tree-sitter node (`Alt-j`, [#1340](https://github.com/helix-editor/helix/pull/1340)) +- `:tree-sitter-subtree` - show the tree-sitter subtree under the primary selection ([#1453](https://github.com/helix-editor/helix/pull/1453), [#1524](https://github.com/helix-editor/helix/pull/1524)) +- Add `Alt-Backspace`, `Alt-<`, `Alt->`, and `Ctrl-j` to insert mode ([#1441](https://github.com/helix-editor/helix/pull/1441)) +- `select_next_sibling`, `select_prev_sibling` - select next and previous tree-sitter nodes (`Alt-l` and `Alt-h`, [#1495](https://github.com/helix-editor/helix/pull/1495)) +- `:buffer-close-all`, `:buffer-close-all!`, `:buffer-close-others`, and `:buffer-close-others!` ([#1677](https://github.com/helix-editor/helix/pull/1677)) +- `:vsplit-new` and `:hsplit-new` - open vertical and horizontal splits with new scratch buffers ([#1763](https://github.com/helix-editor/helix/pull/1763)) +- `:open-config` to open the config file and `:refresh-config` to refresh config after changes ([#1771](https://github.com/helix-editor/helix/pull/1771), [#1803](https://github.com/helix-editor/helix/pull/1803)) + +Usability improvements and fixes: + +- Prevent `:cquit` from ignoring unsaved changes ([#1414](https://github.com/helix-editor/helix/pull/1414)) +- Scrolling view keeps selections ([#1420](https://github.com/helix-editor/helix/pull/1420)) +- Only use shellwords parsing on unix platforms ([`7767703`](https://github.com/helix-editor/helix/commit/7767703)) +- Fix slash in search selector status message ([#1449](https://github.com/helix-editor/helix/pull/1449)) +- Use `std::path::MAIN_SEPARATOR` to determine completion ([`3e4f815`](https://github.com/helix-editor/helix/commit/3e4f815)) +- Expand to current node with `expand_selection` when the node has no children ([#1454](https://github.com/helix-editor/helix/pull/1454)) +- Add vertical and horizontal splits to the buffer picker ([#1502](https://github.com/helix-editor/helix/pull/1502)) +- Use the correct language ID for JavaScript & TypeScript LSP ([#1466](https://github.com/helix-editor/helix/pull/1466)) +- Run format command for all buffers being written ([#1444](https://github.com/helix-editor/helix/pull/1444)) +- Fix panics during resizing ([#1408](https://github.com/helix-editor/helix/pull/1408)) +- Fix auto-pairs with CRLF ([#1470](https://github.com/helix-editor/helix/pull/1470)) +- Fix picker scrolling when the bottom is reached ([#1567](https://github.com/helix-editor/helix/pull/1567)) +- Use markup themes for the markdown component ([#1363](https://github.com/helix-editor/helix/pull/1363)) +- Automatically commit changes to history if not in insert mode ([`2a7ae96`](https://github.com/helix-editor/helix/commit/2a7ae96)) +- Render code-actions as a menu and add padding to popup ([`094a0aa`](https://github.com/helix-editor/helix/commit/094a0aa)) +- Only render menu scrollbar if the menu doesn't fit ([`f10a06f`](https://github.com/helix-editor/helix/commit/f10a06f), [`36b975c`](https://github.com/helix-editor/helix/commit/36b975c)) +- Parse git revision instead of tag for version ([`d3221b0`](https://github.com/helix-editor/helix/commit/d3221b0), [#1674](https://github.com/helix-editor/helix/pull/1674)) +- Fix incorrect last modified buffer ([#1621](https://github.com/helix-editor/helix/pull/1621)) +- Add `PageUp`, `PageDown`, `Ctrl-u`, `Ctrl-d`, `Home`, `End` bindings to the file picker ([#1612](https://github.com/helix-editor/helix/pull/1612)) +- Display buffer IDs in the buffer picker ([#1134](https://github.com/helix-editor/helix/pull/1134)) +- Allow multi-line prompt documentation ([`2af0432`](https://github.com/helix-editor/helix/commit/2af0432)) +- Ignore the `.git` directory from the file picker ([#1604](https://github.com/helix-editor/helix/pull/1604)) +- Allow separate styling for markup heading levels ([#1618](https://github.com/helix-editor/helix/pull/1618)) +- Automatically close popups ([#1285](https://github.com/helix-editor/helix/pull/1285)) +- Allow auto-pairs tokens to be configured ([#1624](https://github.com/helix-editor/helix/pull/1624)) +- Don't indent empty lines in `indent` command ([#1653](https://github.com/helix-editor/helix/pull/1653)) +- Ignore `Enter` keypress when a menu has no selection ([#1704](https://github.com/helix-editor/helix/pull/1704)) +- Show errors when surround deletions and replacements fail ([#1709](https://github.com/helix-editor/helix/pull/1709)) +- Show infobox hints for `mi` and `ma` ([#1686](https://github.com/helix-editor/helix/pull/1686)) +- Highlight matching text in file picker suggestions ([#1635](https://github.com/helix-editor/helix/pull/1635)) +- Allow capturing multiple nodes in textobject queries ([#1611](https://github.com/helix-editor/helix/pull/1611)) +- Make repeat operator work with completion edits ([#1640](https://github.com/helix-editor/helix/pull/1640)) +- Save to the jumplist when searching ([#1718](https://github.com/helix-editor/helix/pull/1718)) +- Fix bug with auto-replacement of components in compositor ([#1711](https://github.com/helix-editor/helix/pull/1711)) +- Use Kakoune logic for `align_selection` ([#1675](https://github.com/helix-editor/helix/pull/1675)) +- Fix `follows` for `nixpkgs` in `flake.nix` ([#1729](https://github.com/helix-editor/helix/pull/1729)) +- Performance improvements for the picker ([`78fba86`](https://github.com/helix-editor/helix/commit/78fba86)) +- Rename infobox theme scopes ([#1741](https://github.com/helix-editor/helix/pull/1741)) +- Fallback to broader scopes if a theme scope is not found ([#1714](https://github.com/helix-editor/helix/pull/1714)) +- Add arrow-keys bindings for tree-sitter sibling selection commands ([#1724](https://github.com/helix-editor/helix/pull/1724)) +- Fix a bug in LSP when creating a file in a folder that does not exist ([#1775](https://github.com/helix-editor/helix/pull/1775)) +- Use `^` and `$` regex location assertions for search ([#1793](https://github.com/helix-editor/helix/pull/1793)) +- Fix register names in `insert_register` command ([#1751](https://github.com/helix-editor/helix/pull/1751)) +- Perform extend line for all selections ([#1804](https://github.com/helix-editor/helix/pull/1804)) +- Prevent panic when moving in an empty picker ([#1786](https://github.com/helix-editor/helix/pull/1786)) +- Fix line number calculations for non CR/CRLF line breaks ([`b4a282f`](https://github.com/helix-editor/helix/commit/b4a282f), [`0b96201`](https://github.com/helix-editor/helix/commit/0b96201)) +- Deploy documentation for `master` builds separately from release docs ([#1783](https://github.com/helix-editor/helix/pull/1783)) + +Themes: + +- Add everforest_light ([#1412](https://github.com/helix-editor/helix/pull/1412)) +- Add gruvbox_light ([#1509](https://github.com/helix-editor/helix/pull/1509)) +- Add modified background to dracula popup ([#1434](https://github.com/helix-editor/helix/pull/1434)) +- Markup support for monokai pro themes ([#1553](https://github.com/helix-editor/helix/pull/1553)) +- Markup support for dracula theme ([#1554](https://github.com/helix-editor/helix/pull/1554)) +- Add `tag` to gruvbox theme ([#1555](https://github.com/helix-editor/helix/pull/1555)) +- Markup support for remaining themes ([#1525](https://github.com/helix-editor/helix/pull/1525)) +- Serika light and dark ([#1566](https://github.com/helix-editor/helix/pull/1566)) +- Fix rose_pine and rose_pine_dawn popup background color ([#1606](https://github.com/helix-editor/helix/pull/1606)) +- Fix hover menu item text color in base16 themes ([#1668](https://github.com/helix-editor/helix/pull/1668)) +- Update markup heading styles for everforest ([#1687](https://github.com/helix-editor/helix/pull/1687)) +- Update markup heading styles for rose_pine themes ([#1706](https://github.com/helix-editor/helix/pull/1706)) +- Style bogster cursors ([`6a6a9ab`](https://github.com/helix-editor/helix/commit/6a6a9ab)) +- Fix `ui.selection` in rose_pine themes ([#1716](https://github.com/helix-editor/helix/pull/1716)) +- Use distinct colors for cursor and matched pair in gruvbox ([#1791](https://github.com/helix-editor/helix/pull/1791)) +- Improve colors for `ui.cursor.match` capture in some themes ([#1862](https://github.com/helix-editor/helix/pull/1862)) + +LSP: + +- Add default language server for JavaScript ([#1457](https://github.com/helix-editor/helix/pull/1457)) +- Add `pom.xml` as maven root directory marker ([#1496](https://github.com/helix-editor/helix/pull/1496)) +- Haskell LSP ([#1556](https://github.com/helix-editor/helix/pull/1556)) +- C-sharp LSP support ([#1788](https://github.com/helix-editor/helix/pull/1788)) +- Clean up Julia LSP config ([#1811](https://github.com/helix-editor/helix/pull/1811)) + +New Languages: + +- llvm-mir ([#1398](https://github.com/helix-editor/helix/pull/1398)) +- regex ([#1362](https://github.com/helix-editor/helix/pull/1362)) +- Make ([#1433](https://github.com/helix-editor/helix/pull/1433), [#1661](https://github.com/helix-editor/helix/pull/1661)) +- git-config ([#1426](https://github.com/helix-editor/helix/pull/1426)) +- Lean ([#1422](https://github.com/helix-editor/helix/pull/1422)) +- Elm ([#1514](https://github.com/helix-editor/helix/pull/1514)) +- GraphQL ([#1515](https://github.com/helix-editor/helix/pull/1515)) +- Twig ([#1602](https://github.com/helix-editor/helix/pull/1602)) +- Rescript ([#1616](https://github.com/helix-editor/helix/pull/1616), [#1863](https://github.com/helix-editor/helix/pull/1863)) +- Erlang ([#1657](https://github.com/helix-editor/helix/pull/1657)) +- Kotlin ([#1689](https://github.com/helix-editor/helix/pull/1689)) +- HCL ([#1705](https://github.com/helix-editor/helix/pull/1705), [#1726](https://github.com/helix-editor/helix/pull/1726)) +- Org ([#1845](https://github.com/helix-editor/helix/pull/1845)) +- Solidity ([#1848](https://github.com/helix-editor/helix/pull/1848), [#1854](https://github.com/helix-editor/helix/pull/1854)) + +Updated Languages and Queries: + +- Textobject and indent queries for c and cpp ([#1293](https://github.com/helix-editor/helix/pull/1293)) +- Fix null and boolean constant highlights for nix ([#1428](https://github.com/helix-editor/helix/pull/1428)) +- Capture markdown link text as `markup.link.text` ([#1456](https://github.com/helix-editor/helix/pull/1456)) +- Update and re-enable Haskell ([#1417](https://github.com/helix-editor/helix/pull/1417), [#1520](https://github.com/helix-editor/helix/pull/1520)) +- Update Go with generics support ([`ddbf036`](https://github.com/helix-editor/helix/commit/ddbf036)) +- Use `tree-sitter-css` for SCSS files ([#1507](https://github.com/helix-editor/helix/pull/1507)) +- Update Zig ([#1501](https://github.com/helix-editor/helix/pull/1501)) +- Update PHP ([#1521](https://github.com/helix-editor/helix/pull/1521)) +- Expand language support for comment injections ([#1527](https://github.com/helix-editor/helix/pull/1527)) +- Use tree-sitter-bash for `.zshrc` and `.bashrc` ([`7d51042`](https://github.com/helix-editor/helix/commit/7d51042)) +- Use tree-sitter-bash for `.bash_profile` ([#1571](https://github.com/helix-editor/helix/pull/1571)) +- Use tree-sitter-bash for `.zshenv` and ZSH files ([#1574](https://github.com/helix-editor/helix/pull/1574)) +- IEx ([#1576](https://github.com/helix-editor/helix/pull/1576)) +- Textobject queries for PHP ([#1601](https://github.com/helix-editor/helix/pull/1601)) +- C-sharp highlight query improvements ([#1795](https://github.com/helix-editor/helix/pull/1795)) +- Git commit performance has been improved on large verbose commits ([#1838](https://github.com/helix-editor/helix/pull/1838)) + +Packaging: + +- The submodules system has been replaced with command-line flags for fetching and building tree-sitter grammars ([#1659](https://github.com/helix-editor/helix/pull/1659)) +- Flake outputs are pushed to Cachix on each push to `master` ([#1721](https://github.com/helix-editor/helix/pull/1721)) +- Update flake's `nix-cargo-integration` to depend on `dream2nix` ([#1758](https://github.com/helix-editor/helix/pull/1758)) # 0.6.0 (2022-01-04) @@ -9,113 +171,114 @@ As usual the following is a brief summary, refer to the git history for a full l Breaking changes: -- fix: Normalize backtab into shift-tab +- fix: Normalize backtab into shift-tab Features: -- Macros ([#1234](https://github.com/helix-editor/helix/pull/1234)) +- Macros ([#1234](https://github.com/helix-editor/helix/pull/1234)) - Add reverse search functionality ([#958](https://github.com/helix-editor/helix/pull/958)) -- Allow keys to be mapped to sequences of commands ([#589](https://github.com/helix-editor/helix/pull/589)) +- Allow keys to be mapped to sequences of commands ([#589](https://github.com/helix-editor/helix/pull/589)) - Make it possible to keybind TypableCommands ([#1169](https://github.com/helix-editor/helix/pull/1169)) - Detect workspace root using language markers ([#1370](https://github.com/helix-editor/helix/pull/1370)) - Add WORD textobject ([#991](https://github.com/helix-editor/helix/pull/991)) -- Add LSP rename_symbol (space-r) ([#1011](https://github.com/helix-editor/helix/pull/1011)) -- Added workspace_symbol_picker ([#1041](https://github.com/helix-editor/helix/pull/1041)) +- Add LSP rename_symbol (`space-r`) ([#1011](https://github.com/helix-editor/helix/pull/1011)) +- Added workspace_symbol_picker ([#1041](https://github.com/helix-editor/helix/pull/1041)) - Detect filetype from shebang line ([#1001](https://github.com/helix-editor/helix/pull/1001)) -- Allow piping from stdin into a buffer on startup ([#996](https://github.com/helix-editor/helix/pull/996)) -- Add auto pairs for same-char pairs ([#1219](https://github.com/helix-editor/helix/pull/1219)) -- Update settings at runtime ([#798](https://github.com/helix-editor/helix/pull/798)) -- Enable thin LTO (cccc194) +- Allow piping from stdin into a buffer on startup ([#996](https://github.com/helix-editor/helix/pull/996)) +- Add auto pairs for same-char pairs ([#1219](https://github.com/helix-editor/helix/pull/1219)) +- Update settings at runtime ([#798](https://github.com/helix-editor/helix/pull/798)) +- Enable thin LTO ([`cccc194`](https://github.com/helix-editor/helix/commit/cccc194)) Commands: -- :wonly -- window only ([#1057](https://github.com/helix-editor/helix/pull/1057)) -- buffer-close (:bc, :bclose) ([#1035](https://github.com/helix-editor/helix/pull/1035)) -- Add : and :goto commands ([#1128](https://github.com/helix-editor/helix/pull/1128)) -- :sort command ([#1288](https://github.com/helix-editor/helix/pull/1288)) -- Add m textobject for pair under cursor ([#961](https://github.com/helix-editor/helix/pull/961)) + +- `:wonly` -- window only ([#1057](https://github.com/helix-editor/helix/pull/1057)) +- buffer-close (`:bc`, `:bclose`) ([#1035](https://github.com/helix-editor/helix/pull/1035)) +- Add `:` and `:goto ` commands ([#1128](https://github.com/helix-editor/helix/pull/1128)) +- `:sort` command ([#1288](https://github.com/helix-editor/helix/pull/1288)) +- Add m textobject for pair under cursor ([#961](https://github.com/helix-editor/helix/pull/961)) - Implement "Goto next buffer / Goto previous buffer" commands ([#950](https://github.com/helix-editor/helix/pull/950)) -- Implement "Goto last modification" command ([#1067](https://github.com/helix-editor/helix/pull/1067)) -- Add trim_selections command ([#1092](https://github.com/helix-editor/helix/pull/1092)) +- Implement "Goto last modification" command ([#1067](https://github.com/helix-editor/helix/pull/1067)) +- Add trim_selections command ([#1092](https://github.com/helix-editor/helix/pull/1092)) - Add movement shortcut for history ([#1088](https://github.com/helix-editor/helix/pull/1088)) - Add command to inc/dec number under cursor ([#1027](https://github.com/helix-editor/helix/pull/1027)) - Add support for dates for increment/decrement -- Align selections (&) ([#1101](https://github.com/helix-editor/helix/pull/1101)) -- Implement no-yank delete/change ([#1099](https://github.com/helix-editor/helix/pull/1099)) -- Implement black hole register ([#1165](https://github.com/helix-editor/helix/pull/1165)) -- gf as goto_file (gf) ([#1102](https://github.com/helix-editor/helix/pull/1102)) -- Add last modified file (gm) ([#1093](https://github.com/helix-editor/helix/pull/1093)) +- Align selections (`&`) ([#1101](https://github.com/helix-editor/helix/pull/1101)) +- Implement no-yank delete/change ([#1099](https://github.com/helix-editor/helix/pull/1099)) +- Implement black hole register ([#1165](https://github.com/helix-editor/helix/pull/1165)) +- `gf` as goto_file (`gf`) ([#1102](https://github.com/helix-editor/helix/pull/1102)) +- Add last modified file (`gm`) ([#1093](https://github.com/helix-editor/helix/pull/1093)) - ensure_selections_forward ([#1393](https://github.com/helix-editor/helix/pull/1393)) - Readline style insert mode ([#1039](https://github.com/helix-editor/helix/pull/1039)) Usability improvements and fixes: -- Detect filetype on :write ([#1141](https://github.com/helix-editor/helix/pull/1141)) -- Add single and double quotes to matching pairs ([#995](https://github.com/helix-editor/helix/pull/995)) +- Detect filetype on `:write` ([#1141](https://github.com/helix-editor/helix/pull/1141)) +- Add single and double quotes to matching pairs ([#995](https://github.com/helix-editor/helix/pull/995)) - Launch with defaults upon invalid config/theme (rather than panicking) ([#982](https://github.com/helix-editor/helix/pull/982)) -- If switching away from an empty scratch buffer, remove it ([#935](https://github.com/helix-editor/helix/pull/935)) +- If switching away from an empty scratch buffer, remove it ([#935](https://github.com/helix-editor/helix/pull/935)) - Truncate the starts of file paths instead of the ends in picker ([#951](https://github.com/helix-editor/helix/pull/951)) -- Truncate the start of file paths in the StatusLine ([#1351](https://github.com/helix-editor/helix/pull/1351)) +- Truncate the start of file paths in the StatusLine ([#1351](https://github.com/helix-editor/helix/pull/1351)) - Prevent picker from previewing binaries or large file ([#939](https://github.com/helix-editor/helix/pull/939)) - Inform when reaching undo/redo bounds ([#981](https://github.com/helix-editor/helix/pull/981)) -- search_impl will only align cursor center when it isn't in view ([#959](https://github.com/helix-editor/helix/pull/959)) -- Add , , , Delete in prompt mode ([#1034](https://github.com/helix-editor/helix/pull/1034)) -- Restore screen position when aborting search ([#1047](https://github.com/helix-editor/helix/pull/1047)) -- Buffer picker: show is_modifier flag ([#1020](https://github.com/helix-editor/helix/pull/1020)) +- search_impl will only align cursor center when it isn't in view ([#959](https://github.com/helix-editor/helix/pull/959)) +- Add ``, ``, ``, Delete in prompt mode ([#1034](https://github.com/helix-editor/helix/pull/1034)) +- Restore screen position when aborting search ([#1047](https://github.com/helix-editor/helix/pull/1047)) +- Buffer picker: show is_modifier flag ([#1020](https://github.com/helix-editor/helix/pull/1020)) - Add commit hash to version info, if present ([#957](https://github.com/helix-editor/helix/pull/957)) - Implement indent-aware delete ([#1120](https://github.com/helix-editor/helix/pull/1120)) - Jump to end char of surrounding pair from any cursor pos ([#1121](https://github.com/helix-editor/helix/pull/1121)) - File picker configuration ([#988](https://github.com/helix-editor/helix/pull/988)) - Fix surround cursor position calculation ([#1183](https://github.com/helix-editor/helix/pull/1183)) - Accept count for goto_window ([#1033](https://github.com/helix-editor/helix/pull/1033)) -- Make kill_to_line_end behave like emacs ([#1235](https://github.com/helix-editor/helix/pull/1235)) -- Only use a single documentation popup ([#1241](https://github.com/helix-editor/helix/pull/1241)) -- ui: popup: Don't allow scrolling past the end of content (3307f44c) -- Open files with spaces in filename, allow opening multiple files ([#1231](https://github.com/helix-editor/helix/pull/1231)) +- Make kill_to_line_end behave like emacs ([#1235](https://github.com/helix-editor/helix/pull/1235)) +- Only use a single documentation popup ([#1241](https://github.com/helix-editor/helix/pull/1241)) +- ui: popup: Don't allow scrolling past the end of content ([`3307f44c`](https://github.com/helix-editor/helix/commit/3307f44c)) +- Open files with spaces in filename, allow opening multiple files ([#1231](https://github.com/helix-editor/helix/pull/1231)) - Allow paste commands to take a count ([#1261](https://github.com/helix-editor/helix/pull/1261)) -- Auto pairs selection ([#1254](https://github.com/helix-editor/helix/pull/1254)) -- Use a fuzzy matcher for commands ([#1386](https://github.com/helix-editor/helix/pull/1386)) -- Add c-s to pick word under doc cursor to prompt line & search completion ([#831](https://github.com/helix-editor/helix/pull/831)) -- Fix :earlier/:later missing changeset update ([#1069](https://github.com/helix-editor/helix/pull/1069)) +- Auto pairs selection ([#1254](https://github.com/helix-editor/helix/pull/1254)) +- Use a fuzzy matcher for commands ([#1386](https://github.com/helix-editor/helix/pull/1386)) +- Add `` to pick word under doc cursor to prompt line & search completion ([#831](https://github.com/helix-editor/helix/pull/831)) +- Fix `:earlier`/`:later` missing changeset update ([#1069](https://github.com/helix-editor/helix/pull/1069)) - Support extend for multiple goto ([#909](https://github.com/helix-editor/helix/pull/909)) - Add arrow-key bindings for window switching ([#933](https://github.com/helix-editor/helix/pull/933)) - Implement key ordering for info box ([#952](https://github.com/helix-editor/helix/pull/952)) LSP: -- Implement MarkedString rendering (e128a8702) -- Don't panic if init fails (d31bef7) +- Implement MarkedString rendering ([`e128a8702`](https://github.com/helix-editor/helix/commit/e128a8702)) +- Don't panic if init fails ([`d31bef7`](https://github.com/helix-editor/helix/commit/d31bef7)) - Configurable diagnostic severity ([#1325](https://github.com/helix-editor/helix/pull/1325)) - Resolve completion item ([#1315](https://github.com/helix-editor/helix/pull/1315)) - Code action command support ([#1304](https://github.com/helix-editor/helix/pull/1304)) Grammars: -- Adds mint language server ([#974](https://github.com/helix-editor/helix/pull/974)) +- Adds mint language server ([#974](https://github.com/helix-editor/helix/pull/974)) - Perl ([#978](https://github.com/helix-editor/helix/pull/978)) ([#1280](https://github.com/helix-editor/helix/pull/1280)) -- GLSL ([#993](https://github.com/helix-editor/helix/pull/993)) -- Racket ([#1143](https://github.com/helix-editor/helix/pull/1143)) -- WGSL ([#1166](https://github.com/helix-editor/helix/pull/1166)) +- GLSL ([#993](https://github.com/helix-editor/helix/pull/993)) +- Racket ([#1143](https://github.com/helix-editor/helix/pull/1143)) +- WGSL ([#1166](https://github.com/helix-editor/helix/pull/1166)) - LLVM ([#1167](https://github.com/helix-editor/helix/pull/1167)) ([#1388](https://github.com/helix-editor/helix/pull/1388)) ([#1409](https://github.com/helix-editor/helix/pull/1409)) ([#1398](https://github.com/helix-editor/helix/pull/1398)) -- Markdown (49e06787) +- Markdown ([`49e06787`](https://github.com/helix-editor/helix/commit/49e06787)) - Scala ([#1278](https://github.com/helix-editor/helix/pull/1278)) - Dart ([#1250](https://github.com/helix-editor/helix/pull/1250)) -- Fish ([#1308](https://github.com/helix-editor/helix/pull/1308)) +- Fish ([#1308](https://github.com/helix-editor/helix/pull/1308)) - Dockerfile ([#1303](https://github.com/helix-editor/helix/pull/1303)) - Git (commit, rebase, diff) ([#1338](https://github.com/helix-editor/helix/pull/1338)) ([#1402](https://github.com/helix-editor/helix/pull/1402)) ([#1373](https://github.com/helix-editor/helix/pull/1373)) - tree-sitter-comment ([#1300](https://github.com/helix-editor/helix/pull/1300)) - Highlight comments in c, cpp, cmake and llvm ([#1309](https://github.com/helix-editor/helix/pull/1309)) -- Improve yaml syntax highlighting highlighting ([#1294](https://github.com/helix-editor/helix/pull/1294)) +- Improve yaml syntax highlighting highlighting ([#1294](https://github.com/helix-editor/helix/pull/1294)) - Improve rust syntax highlighting ([#1295](https://github.com/helix-editor/helix/pull/1295)) -- Add textobjects and indents to cmake ([#1307](https://github.com/helix-editor/helix/pull/1307)) +- Add textobjects and indents to cmake ([#1307](https://github.com/helix-editor/helix/pull/1307)) - Add textobjects and indents to c and cpp ([#1293](https://github.com/helix-editor/helix/pull/1293)) New themes: -- Solarized dark ([#999](https://github.com/helix-editor/helix/pull/999)) -- Solarized light ([#1010](https://github.com/helix-editor/helix/pull/1010)) +- Solarized dark ([#999](https://github.com/helix-editor/helix/pull/999)) +- Solarized light ([#1010](https://github.com/helix-editor/helix/pull/1010)) - Spacebones light ([#1131](https://github.com/helix-editor/helix/pull/1131)) -- Monokai Pro ([#1206](https://github.com/helix-editor/helix/pull/1206)) +- Monokai Pro ([#1206](https://github.com/helix-editor/helix/pull/1206)) - Base16 Light and Terminal ([#1078](https://github.com/helix-editor/helix/pull/1078)) - - and a default 16 color theme, truecolor detection + - and a default 16 color theme, truecolor detection - Dracula ([#1258](https://github.com/helix-editor/helix/pull/1258)) # 0.5.0 (2021-10-28) @@ -142,19 +305,19 @@ Features: - LSP compatibility greatly improved for some implementations (Julia, Python, Typescript) - Autocompletion! Completion now triggers automatically after a set idle timeout - Completion documentation is now displayed next to the popup ([#691](https://github.com/helix-editor/helix/pull/691)) -- Treesitter textobjects (select a function via `mf`, class via `mc`) ([#728](https://github.com/helix-editor/helix/pull/728)) -- Global search across entire workspace `space+/` ([#651](https://github.com/helix-editor/helix/pull/651)) +- Treesitter textobjects (select a function via `mf`, class via `mc`) ([#728](https://github.com/helix-editor/helix/pull/728)) +- Global search across entire workspace `space+/` ([#651](https://github.com/helix-editor/helix/pull/651)) - Relative line number support ([#485](https://github.com/helix-editor/helix/pull/485)) -- Prompts now store a history (72cf86e) +- Prompts now store a history ([`72cf86e`](https://github.com/helix-editor/helix/commit/72cf86e)) - `:vsplit` and `:hsplit` commands ([#639](https://github.com/helix-editor/helix/pull/639)) - `C-w h/j/k/l` can now be used to navigate between splits ([#860](https://github.com/helix-editor/helix/pull/860)) - `C-j` and `C-k` are now alternative keybindings to `C-n` and `C-p` in the UI ([#876](https://github.com/helix-editor/helix/pull/876)) - Shell commands (shell-pipe, pipe-to, shell-insert-output, shell-append-output, keep-pipe) ([#547](https://github.com/helix-editor/helix/pull/547)) - Searching now defaults to smart case search (case insensitive unless uppercase is used) ([#761](https://github.com/helix-editor/helix/pull/761)) - The preview pane was improved to highlight and center line ranges -- The user `languages.toml` is now merged into defaults, no longer need to copy the entire file (dc57f8dc) +- The user `languages.toml` is now merged into defaults, no longer need to copy the entire file ([`dc57f8dc`](https://github.com/helix-editor/helix/commit/dc57f8dc)) - Show hidden files in completions ([#648](https://github.com/helix-editor/helix/pull/648)) -- Grammar injections are now properly handled (dd0b15e) +- Grammar injections are now properly handled ([`dd0b15e`](https://github.com/helix-editor/helix/commit/dd0b15e)) - `v` in select mode now switches back to normal mode ([#660](https://github.com/helix-editor/helix/pull/660)) - View mode can now be triggered as a "sticky" mode ([#719](https://github.com/helix-editor/helix/pull/719)) - `f`/`t` and object selection motions can now be repeated via `Alt-.` ([#891](https://github.com/helix-editor/helix/pull/891)) @@ -172,7 +335,7 @@ New grammars: - Vue ([#787](https://github.com/helix-editor/helix/pull/787)) - Tree-sitter queries ([#845](https://github.com/helix-editor/helix/pull/845)) - CMake ([#888](https://github.com/helix-editor/helix/pull/888)) -- Elixir (we switched over to the official grammar) (6c0786e) +- Elixir (we switched over to the official grammar) ([`6c0786e`](https://github.com/helix-editor/helix/commit/6c0786e)) - Language server definitions for Nix and Elixir ([#725](https://github.com/helix-editor/helix/pull/725)) - Python now uses `pylsp` instead of `pyls` - Python now supports indentation @@ -189,21 +352,22 @@ Fixes: - Fix crash on empty rust file ([#592](https://github.com/helix-editor/helix/pull/592)) - Exit select mode after toggle comment ([#598](https://github.com/helix-editor/helix/pull/598)) -- Pin popups with no positioning to the initial position (12ea3888) -- xsel copy should not freeze the editor (6dd7dc4) -- `*` now only sets the search register and doesn't jump to the next occurrence (3426285) +- Pin popups with no positioning to the initial position ([`12ea3888`](https://github.com/helix-editor/helix/commit/12ea3888)) +- xsel copy should not freeze the editor ([`6dd7dc4`](https://github.com/helix-editor/helix/commit/6dd7dc4)) +- `*` now only sets the search register and doesn't jump to the next occurrence ([`3426285`](https://github.com/helix-editor/helix/commit/3426285)) - Goto line start/end commands extend when in select mode ([#739](https://github.com/helix-editor/helix/pull/739)) -- Fix documentation popups sometimes not getting fully highlighted (066367c) -- Refactor apply_workspace_edit to remove assert (b02d872) -- Wrap around the top of the picker menu when scrolling (c7d6e44) -- Don't allow closing the last split if there's unsaved changes (3ff5b00) -- Indentation used different default on hx vs hx new_file.txt (c913bad) +- Fix documentation popups sometimes not getting fully highlighted ([`066367c`](https://github.com/helix-editor/helix/commit/066367c)) +- Refactor apply_workspace_edit to remove assert ([`b02d872`](https://github.com/helix-editor/helix/commit/b02d872)) +- Wrap around the top of the picker menu when scrolling ([`c7d6e44`](https://github.com/helix-editor/helix/commit/c7d6e44)) +- Don't allow closing the last split if there's unsaved changes ([`3ff5b00`](https://github.com/helix-editor/helix/commit/3ff5b00)) +- Indentation used different default on hx vs hx new_file.txt ([`c913bad`](https://github.com/helix-editor/helix/commit/c913bad)) # 0.4.1 (2021-08-14) A minor release that includes: + - A fix for rendering glitches that would occur after editing with multiple selections. -- CI fix for grammars not being cross-compiled for aarch64 +- CI fix for grammars not being cross-compiled for aarch64 # 0.4.0 (2021-08-13) @@ -223,10 +387,10 @@ selections in the future as well as resolves many bugs and edge cases. - Autoinfo: `whichkey`-like popups which show available sub-mode shortcuts ([#316](https://github.com/helix-editor/helix/pull/316)) - Added WORD movements (W/B/E) ([#390](https://github.com/helix-editor/helix/pull/390)) - Vertical selections (repeat selection above/below) ([#462](https://github.com/helix-editor/helix/pull/462)) -- Selection rotation via `(` and `)` ([66a90130](https://github.com/helix-editor/helix/commit/66a90130a5f99d769e9f6034025297f78ecaa3ec)) -- Selection contents rotation via `Alt-(` and `Alt-)` ([02cba2a](https://github.com/helix-editor/helix/commit/02cba2a7f403f48eccb18100fb751f7b42373dba)) -- Completion behavior improvements ([f917b5a4](https://github.com/helix-editor/helix/commit/f917b5a441ff3ae582358b6939ffbf889f4aa530), [627b899](https://github.com/helix-editor/helix/commit/627b89931576f7af86166ae8d5cbc55537877473)) -- Fixed a language server crash ([385a6b5a](https://github.com/helix-editor/helix/commit/385a6b5a1adddfc26e917982641530e1a7c7aa81)) +- Selection rotation via `(` and `)` ([`66a90130`](https://github.com/helix-editor/helix/commit/66a90130a5f99d769e9f6034025297f78ecaa3ec)) +- Selection contents rotation via `Alt-(` and `Alt-)` ([`02cba2a`](https://github.com/helix-editor/helix/commit/02cba2a7f403f48eccb18100fb751f7b42373dba)) +- Completion behavior improvements ([`f917b5a4`](https://github.com/helix-editor/helix/commit/f917b5a441ff3ae582358b6939ffbf889f4aa530), [`627b899`](https://github.com/helix-editor/helix/commit/627b89931576f7af86166ae8d5cbc55537877473)) +- Fixed a language server crash ([`385a6b5a`](https://github.com/helix-editor/helix/commit/385a6b5a1adddfc26e917982641530e1a7c7aa81)) - Case change commands (`` ` ``, `~`, ````) ([#441](https://github.com/helix-editor/helix/pull/441)) - File pickers (including goto) now provide a preview! ([#534](https://github.com/helix-editor/helix/pull/534)) - Injection query support. Rust macro calls and embedded languages are now properly highlighted ([#430](https://github.com/helix-editor/helix/pull/430)) @@ -242,7 +406,7 @@ selections in the future as well as resolves many bugs and edge cases. - Comment toggling now uses a language specific comment token ([#463](https://github.com/helix-editor/helix/pull/463)) - Julia support ([#413](https://github.com/helix-editor/helix/pull/413)) - Java support ([#448](https://github.com/helix-editor/helix/pull/448)) -- Prompts have an (in-memory) history ([63e54e30](https://github.com/helix-editor/helix/commit/63e54e30a74bb0d1d782877ddbbcf95f2817d061)) +- Prompts have an (in-memory) history ([`63e54e30`](https://github.com/helix-editor/helix/commit/63e54e30a74bb0d1d782877ddbbcf95f2817d061)) # 0.3.0 (2021-06-27) @@ -256,7 +420,7 @@ Highlights: - Support for other line endings (CRLF). Significantly improved Windows support. ([#224](https://github.com/helix-editor/helix/pull/224)) - Encodings other than UTF-8 are now supported! ([#228](https://github.com/helix-editor/helix/pull/228)) - Key bindings can now be configured via a `config.toml` file ([#268](https://github.com/helix-editor/helix/pull/268)) -- Theme can now be configured and changed at runtime ([please feel free to contribute more themes!](https://github.com/helix-editor/helix/tree/master/runtime/themes)) ([#267](https://github.com/helix-editor/helix/pull/267)) +- Theme can now be configured and changed at runtime. ([Please feel free to contribute more themes!](https://github.com/helix-editor/helix/tree/master/runtime/themes)) ([#267](https://github.com/helix-editor/helix/pull/267)) - System clipboard yank/paste is now supported! ([#310](https://github.com/helix-editor/helix/pull/310)) - Surround commands were implemented ([#320](https://github.com/helix-editor/helix/pull/320)) @@ -273,7 +437,7 @@ Features: - Code is being migrated from helix-term to helix-view (prerequisite for alternative frontends) ([#366](https://github.com/helix-editor/helix/pull/366)) - `x` and `X` merged - ([f41688d9](https://github.com/helix-editor/helix/commit/f41688d960ef89c29c4a51c872b8406fb8f81a85)) + ([`f41688d9`](https://github.com/helix-editor/helix/commit/f41688d960ef89c29c4a51c872b8406fb8f81a85)) Fixes: @@ -281,12 +445,12 @@ Fixes: - A bunch of bugs regarding `o`/`O` behavior ([#281](https://github.com/helix-editor/helix/pull/281)) - `~` expansion now works in file completion ([#284](https://github.com/helix-editor/helix/pull/284)) - Several UI related overflow crashes ([#318](https://github.com/helix-editor/helix/pull/318)) -- Fix a test failure occuring only on `test --release` ([4f108ab1](https://github.com/helix-editor/helix/commit/4f108ab1b2197809506bd7305ad903a3525eabfa)) +- Fix a test failure occuring only on `test --release` ([`4f108ab1`](https://github.com/helix-editor/helix/commit/4f108ab1b2197809506bd7305ad903a3525eabfa)) - Prompts now support unicode input ([#295](https://github.com/helix-editor/helix/pull/295)) - Completion documentation no longer overlaps the popup ([#322](https://github.com/helix-editor/helix/pull/322)) -- Fix a crash when trying to select `^` ([9c534614](https://github.com/helix-editor/helix/commit/9c53461429a3e72e3b1fb87d7ca490e168d7dee2)) -- Prompt completions are now paginated ([39dc09e6](https://github.com/helix-editor/helix/commit/39dc09e6c4172299bc79de4c1c52288d3f624bd7)) -- Goto did not work on Windows ([503ca112](https://github.com/helix-editor/helix/commit/503ca112ae57ebdf3ea323baf8940346204b46d2)) +- Fix a crash when trying to select `^` ([`9c534614`](https://github.com/helix-editor/helix/commit/9c53461429a3e72e3b1fb87d7ca490e168d7dee2)) +- Prompt completions are now paginated ([`39dc09e6`](https://github.com/helix-editor/helix/commit/39dc09e6c4172299bc79de4c1c52288d3f624bd7)) +- Goto did not work on Windows ([`503ca112`](https://github.com/helix-editor/helix/commit/503ca112ae57ebdf3ea323baf8940346204b46d2)) # 0.2.1 diff --git a/Cargo.lock b/Cargo.lock index cff9c9914..85438e01e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,9 +13,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.53" +version = "1.0.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94a45b455c14666b85fc40a019e8ab9eb75e3a124e05494f5397122bc9eb06e0" +checksum = "4361135be9122e0870de935d7c439aef945b9f9ddd4199a553b5270b49c82a27" [[package]] name = "arc-swap" @@ -25,9 +25,9 @@ checksum = "c5d78ce20460b82d3fa150275ed9d55e21064fc7951177baacf86a145c4a4b1f" [[package]] name = "autocfg" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" [[package]] name = "bitflags" @@ -66,9 +66,9 @@ checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" [[package]] name = "cc" -version = "1.0.72" +version = "1.0.73" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22a9137b95ea06864e018375b72adfb7db6e6f68cfc8df5a04d00288050485ee" +checksum = "2fff2a6927b3bb87f9595d67196a70493f627687a71d87a0d692242c33f58c11" [[package]] name = "cfg-if" @@ -121,9 +121,9 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e5bed1f1c269533fa816a0a5492b3545209a205ca1a54842be180eb63a16a6" +checksum = "0bf124c720b7686e3c2663cf54062ab0f68a88af2fb6a030e87e30bf721fcb38" dependencies = [ "cfg-if", "lazy_static", @@ -131,16 +131,16 @@ dependencies = [ [[package]] name = "crossterm" -version = "0.23.0" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77b75a27dc8d220f1f8521ea69cd55a34d720a200ebb3a624d9aa19193d3b432" +checksum = "f1fd7173631a4e9e2ca8b32ae2fad58aab9843ea5aaf56642661937d87e28a3e" dependencies = [ "bitflags", "crossterm_winapi", "futures-core", "libc", - "mio", - "parking_lot 0.12.0", + "mio 0.7.14", + "parking_lot", "signal-hook", "signal-hook-mio", "winapi", @@ -293,13 +293,13 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "418d37c8b1d42553c93648be529cb70f920d3baf8ef469b74b9638df426e0b4c" +checksum = "d39cd93900197114fa1fcb7ae84ca742095eed9442088988ae74fa744e930e77" dependencies = [ "cfg-if", "libc", - "wasi", + "wasi 0.10.2+wasi-snapshot-preview1", ] [[package]] @@ -362,7 +362,7 @@ dependencies = [ "chrono", "encoding_rs", "etcetera", - "helix-syntax", + "helix-loader", "log", "once_cell", "quickcheck", @@ -393,6 +393,22 @@ dependencies = [ "serde_json", "thiserror", "tokio", + "which", +] + +[[package]] +name = "helix-loader" +version = "0.6.0" +dependencies = [ + "anyhow", + "cc", + "etcetera", + "libloading", + "once_cell", + "serde", + "threadpool", + "toml", + "tree-sitter", ] [[package]] @@ -411,17 +427,7 @@ dependencies = [ "thiserror", "tokio", "tokio-stream", -] - -[[package]] -name = "helix-syntax" -version = "0.6.0" -dependencies = [ - "anyhow", - "cc", - "libloading", - "threadpool", - "tree-sitter", + "which", ] [[package]] @@ -429,6 +435,7 @@ name = "helix-term" version = "0.6.0" dependencies = [ "anyhow", + "arc-swap", "chrono", "content_inspector", "crossterm", @@ -439,6 +446,7 @@ dependencies = [ "grep-searcher", "helix-core", "helix-dap", + "helix-loader", "helix-lsp", "helix-tui", "helix-view", @@ -447,6 +455,7 @@ dependencies = [ "num_cpus", "once_cell", "pulldown-cmark", + "retain_mut", "serde", "serde_json", "signal-hook", @@ -454,6 +463,7 @@ dependencies = [ "tokio", "tokio-stream", "toml", + "which", ] [[package]] @@ -474,6 +484,7 @@ name = "helix-view" version = "0.6.0" dependencies = [ "anyhow", + "arc-swap", "bitflags", "chardetng", "clipboard-win", @@ -486,6 +497,7 @@ dependencies = [ "log", "once_cell", "serde", + "serde_json", "slotmap", "tokio", "tokio-stream", @@ -532,15 +544,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "instant" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" -dependencies = [ - "cfg-if", -] - [[package]] name = "itoa" version = "1.0.1" @@ -568,9 +571,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.117" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e74d72e0f9b65b5b4ca49a346af3976df0f9c61d550727f349ecd559f251a26c" +checksum = "efaa7b300f3b5fe8eb6bf21ce3895e1751d9665086af2d64b42f19701015ff4f" [[package]] name = "libloading" @@ -593,18 +596,18 @@ dependencies = [ [[package]] name = "log" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710" +checksum = "6389c490849ff5bc16be905ae24bc913a9c8892e19b2341dbc175e14c341c2b8" dependencies = [ "cfg-if", ] [[package]] name = "lsp-types" -version = "0.92.0" +version = "0.92.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a69d4142d51b208c9fc3cea68b1a7fcef30354e7aa6ccad07250fd8430fc76" +checksum = "c79d4897790e8fd2550afa6d6125821edb5716e60e0e285046e070f0f6a06e0e" dependencies = [ "bitflags", "serde", @@ -647,6 +650,20 @@ dependencies = [ "winapi", ] +[[package]] +name = "mio" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba42135c6a5917b9db9cd7b293e5409e1c6b041e6f9825e92e55a894c63b6f8" +dependencies = [ + "libc", + "log", + "miow", + "ntapi", + "wasi 0.11.0+wasi-snapshot-preview1", + "winapi", +] + [[package]] name = "miow" version = "0.3.7" @@ -658,9 +675,9 @@ dependencies = [ [[package]] name = "ntapi" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6bb902e437b6d86e03cce10a7e2af662292c5dfef23b65899ea3ac9354ad44" +checksum = "c28774a7fd2fbb4f0babd8237ce554b73af68021b5f695a3cebd6c59bac0980f" dependencies = [ "winapi", ] @@ -696,20 +713,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da32515d9f6e6e489d7bc9d84c71b060db7247dc035bbe44eac88cf87486d8d5" - -[[package]] -name = "parking_lot" -version = "0.11.2" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" -dependencies = [ - "instant", - "lock_api", - "parking_lot_core 0.8.5", -] +checksum = "87f3e037eac156d1775da914196f0f37741a274155e34a0b7e427c35d2a2ecb9" [[package]] name = "parking_lot" @@ -718,21 +724,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87f5ec2493a61ac0506c0f4199f99070cbe83857b0337006a30f3e6719b8ef58" dependencies = [ "lock_api", - "parking_lot_core 0.9.1", -] - -[[package]] -name = "parking_lot_core" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d76e8e1493bcac0d2766c42737f34458f1c8c50c0d23bcb24ea953affb273216" -dependencies = [ - "cfg-if", - "instant", - "libc", - "redox_syscall", - "smallvec", - "winapi", + "parking_lot_core", ] [[package]] @@ -806,9 +798,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e7573632e6454cf6b99d7aac4ccca54be06da05aca2ef7423d22d27d4d4bcd8" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "rand_core", ] @@ -824,9 +816,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.2.10" +version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8383f39639269cde97d255a32bdb68c047337295414940c68bdd30c2e13203ff" +checksum = "8380fe0152551244f0747b1bf41737e0f8a74f97a14ccefd1148187271634f3c" dependencies = [ "bitflags", ] @@ -843,9 +835,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.5.4" +version = "1.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d07a8629359eb56f1e2fb1652bb04212c072a87ba68546a04065d525673ac461" +checksum = "1a11647b6b25ff05a515cb92c365cec08801e83423a235b51e231e1808747286" dependencies = [ "aho-corasick", "memchr", @@ -864,13 +856,20 @@ version = "0.6.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" +[[package]] +name = "retain_mut" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c31b5c4033f8fdde8700e4657be2c497e7288f01515be52168c631e2e4d4086" + [[package]] name = "ropey" -version = "1.3.2" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6b9aa65bcd9f308d37c7158b4a1afaaa32b8450213e20c9b98e7d5b3cc2fec3" +checksum = "fa0dd9b26e2a102b33d400b7b7d196c81a4014eb96eda90b1c5b48d7215d9633" dependencies = [ "smallvec", + "str_indices", ] [[package]] @@ -953,7 +952,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29fd5867f1c4f2c5be079aee7a2adf1152ebb04a4bc4d341f504b7dece607ed4" dependencies = [ "libc", - "mio", + "mio 0.7.14", "signal-hook", ] @@ -1007,11 +1006,23 @@ checksum = "f2dd574626839106c320a323308629dcb1acfc96e32a8cba364ddc61ac23ee83" [[package]] name = "smartstring" -version = "0.2.9" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31aa6a31c0c2b21327ce875f7e8952322acfcfd0c27569a6e18a647281352c9b" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" dependencies = [ + "autocfg", "static_assertions", + "version_check", +] + +[[package]] +name = "socket2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66d72b759436ae32898a2af0a14218dbf55efde3feeb170eb623637db85ee1e0" +dependencies = [ + "libc", + "winapi", ] [[package]] @@ -1026,11 +1037,17 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d44a3643b4ff9caf57abcee9c2c621d6c03d9135e0d8b589bd9afb5992cb176a" +[[package]] +name = "str_indices" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "283baa48c486e4c5e27b4d92c435db9eaceac236a74dab5e3293570e2c3fa4aa" + [[package]] name = "syn" -version = "1.0.86" +version = "1.0.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a65b3f4ffa0092e9887669db0eae07941f023991ab58ea44da8fe8e2d511c6b" +checksum = "ea297be220d52398dcc07ce15a209fce436d361735ac1db700cab3b6cdfb9f54" dependencies = [ "proc-macro2", "quote", @@ -1092,19 +1109,20 @@ checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" [[package]] name = "tokio" -version = "1.16.1" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c27a64b625de6d309e8c57716ba93021dccf1b3b5c97edd6d3dd2d2135afc0a" +checksum = "2af73ac49756f3f7c01172e34a23e5d0216f6c32333757c2c61feb2bbff5a5ee" dependencies = [ "bytes", "libc", "memchr", - "mio", + "mio 0.8.1", "num_cpus", "once_cell", - "parking_lot 0.11.2", + "parking_lot", "pin-project-lite", "signal-hook-registry", + "socket2", "tokio-macros", "winapi", ] @@ -1142,9 +1160,9 @@ dependencies = [ [[package]] name = "tree-sitter" -version = "0.20.4" +version = "0.20.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e34327f8eac545e3f037382471b2b19367725a242bba7bc45edb9efb49fe39a" +checksum = "09b3b781640108d29892e8b9684642d2cda5ea05951fd58f0fea1db9edeb9b71" dependencies = [ "cc", "regex", @@ -1234,11 +1252,17 @@ version = "0.10.2+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + [[package]] name = "which" -version = "4.2.4" +version = "4.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a5a7e487e921cf220206864a94a89b6c6905bfc19f1057fa26a4cb360e5c1d2" +checksum = "5c4fb54e6113b6a8772ee41c3404fb0301ac79604489467e0a9ce1f3e97c24ae" dependencies = [ "either", "lazy_static", diff --git a/Cargo.toml b/Cargo.toml index 36dcb09f3..780811f78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,15 +4,15 @@ members = [ "helix-view", "helix-term", "helix-tui", - "helix-syntax", "helix-lsp", "helix-dap", + "helix-loader", "xtask", ] -# Build helix-syntax in release mode to make the code path faster in development. -# [profile.dev.package."helix-syntax"] -# opt-level = 3 +default-members = [ + "helix-term" +] [profile.dev] split-debuginfo = "unpacked" @@ -20,3 +20,10 @@ split-debuginfo = "unpacked" [profile.release] lto = "thin" # debug = true + +[profile.opt] +inherits = "release" +lto = "fat" +codegen-units = 1 +# strip = "debuginfo" # TODO: or strip = true +opt-level = 3 diff --git a/README.md b/README.md index 71010cc82..4052d9411 100644 --- a/README.md +++ b/README.md @@ -36,16 +36,18 @@ We provide packaging for various distributions, but here's a quick method to build from source. ``` -git clone --recurse-submodules --shallow-submodules -j8 https://github.com/helix-editor/helix +git clone https://github.com/helix-editor/helix cd helix cargo install --path helix-term +hx --grammar fetch +hx --grammar build ``` -This will install the `hx` binary to `$HOME/.cargo/bin`. +This will install the `hx` binary to `$HOME/.cargo/bin` and build tree-sitter grammars. Helix also needs its runtime files so make sure to copy/symlink the `runtime/` directory into the config directory (for example `~/.config/helix/runtime` on Linux/macOS, or `%AppData%/helix/runtime` on Windows). -This location can be overriden via the `HELIX_RUNTIME` environment variable. +This location can be overridden via the `HELIX_RUNTIME` environment variable. Packages already solve this for you by wrapping the `hx` binary with a wrapper that sets the variable to the install dir. @@ -56,6 +58,7 @@ that sets the variable to the install dir. [![Packaging status](https://repology.org/badge/vertical-allrepos/helix.svg)](https://repology.org/project/helix/versions) ## MacOS + Helix can be installed on MacOS through homebrew via: ``` diff --git a/TODO.md b/TODO.md deleted file mode 100644 index ab94cf9a0..000000000 --- a/TODO.md +++ /dev/null @@ -1,19 +0,0 @@ - -- [ ] completion isIncomplete support -- [ ] respect view fullscreen flag -- [ ] Implement marks (superset of Selection/Range) - -- [ ] = for auto indent line/selection -- [ ] lsp: signature help - -2 -- [ ] store some state between restarts: file positions, prompt history -- [ ] highlight matched characters in picker - -3 -- [ ] diff mode with highlighting? -- [ ] snippet support (tab to jump between marks) -- [ ] gamelisp/wasm scripting - -X -- [ ] rendering via skulpin/skia or raw wgpu diff --git a/VERSION b/VERSION new file mode 100644 index 000000000..24b267470 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +22.03 \ No newline at end of file diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index a8f165c01..ef214b12a 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -1,5 +1,7 @@ # Summary +[Helix](./title-page.md) + - [Installation](./install.md) - [Usage](./usage.md) - [Keymap](./keymap.md) @@ -14,3 +16,4 @@ - [Guides](./guides/README.md) - [Adding Languages](./guides/adding_languages.md) - [Adding Textobject Queries](./guides/textobject.md) + - [Adding Indent Queries](./guides/indent.md) diff --git a/book/src/configuration.md b/book/src/configuration.md index 8048f5484..9036b5018 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md @@ -5,6 +5,8 @@ To override global configuration parameters, create a `config.toml` file located * Linux and Mac: `~/.config/helix/config.toml` * Windows: `%AppData%\helix\config.toml` +> Hint: You can easily open the config file by typing `:config-open` within Helix normal mode. + Example config: ```toml @@ -35,14 +37,20 @@ hidden = false | `scroll-lines` | Number of lines to scroll per scroll wheel step. | `3` | | `shell` | Shell to use when running external commands. | Unix: `["sh", "-c"]`
Windows: `["cmd", "/C"]` | | `line-number` | Line number display: `absolute` simply shows each line's number, while `relative` shows the distance from the current line. When unfocused or in insert mode, `relative` will still show absolute line numbers. | `absolute` | -| `smart-case` | Enable smart case regex searching (case insensitive unless pattern contains upper case characters) | `true` | -| `auto-pairs` | Enable automatic insertion of pairs to parenthese, brackets, etc. | `true` | | `auto-completion` | Enable automatic pop up of auto-completion. | `true` | | `idle-timeout` | Time in milliseconds since last keypress before idle timers trigger. Used for autocompletion, set to 0 for instant. | `400` | | `completion-trigger-len` | The min-length of word under cursor to trigger autocompletion | `2` | | `auto-info` | Whether to display infoboxes | `true` | | `true-color` | Set to `true` to override automatic detection of terminal truecolor support in the event of a false negative. | `false` | +### `[editor.lsp]` Section + +| Key | Description | Default | +| --- | ----------- | ------- | +| `display-messages` | Display LSP progress messages below statusline[^1] | `false` | + +[^1]: A progress spinner is always shown in the statusline beside the file path. + ### `[editor.cursor-shape]` Section Defines the shape of cursor in each mode. Note that due to limitations @@ -76,10 +84,53 @@ available, which is not defined by default. |`git-exclude` | Enables reading `.git/info/exclude` files. | true |`max-depth` | Set with an integer value for maximum depth to recurse. | Defaults to `None`. -## LSP +### `[editor.auto-pairs]` Section + +Enable automatic insertion of pairs to parentheses, brackets, etc. Can be +a simple boolean value, or a specific mapping of pairs of single characters. + +| Key | Description | +| --- | ----------- | +| `false` | Completely disable auto pairing, regardless of language-specific settings +| `true` | Use the default pairs: (){}[]''""`` +| Mapping of pairs | e.g. `{ "(" = ")", "{" = "}", ... }` + +Example -To display all language server messages in the status line add the following to your `config.toml`: ```toml -[lsp] -display-messages = true +[editor.auto-pairs] +'(' = ')' +'{' = '}' +'[' = ']' +'"' = '"' +'`' = '`' +'<' = '>' ``` + +Additionally, this setting can be used in a language config. Unless +the editor setting is `false`, this will override the editor config in +documents with this language. + +Example `languages.toml` that adds <> and removes '' + +```toml +[[language]] +name = "rust" + +[language.auto-pairs] +'(' = ')' +'{' = '}' +'[' = ']' +'"' = '"' +'`' = '`' +'<' = '>' +``` + +### `[editor.search]` Section + +Search specific options. + +| Key | Description | Default | +|--|--|---------| +| `smart-case` | Enable smart case regex searching (case insensitive unless pattern contains upper case characters) | `true` | +| `wrap-around`| Whether the search should wrap after depleting the matches | `true` | diff --git a/book/src/generated/lang-support.md b/book/src/generated/lang-support.md index 894050731..34e65e46c 100644 --- a/book/src/generated/lang-support.md +++ b/book/src/generated/lang-support.md @@ -2,7 +2,7 @@ | --- | --- | --- | --- | --- | | bash | ✓ | | | `bash-language-server` | | c | ✓ | ✓ | ✓ | `clangd` | -| c-sharp | ✓ | | | | +| c-sharp | ✓ | | | `OmniSharp` | | cmake | ✓ | ✓ | ✓ | `cmake-language-server` | | comment | ✓ | | | | | cpp | ✓ | ✓ | ✓ | `clangd` | @@ -11,22 +11,25 @@ | dockerfile | ✓ | | | `docker-langserver` | | elixir | ✓ | | | `elixir-ls` | | elm | ✓ | | | `elm-language-server` | -| erlang | ✓ | | | | +| erlang | ✓ | | | `erlang_ls` | | fish | ✓ | ✓ | ✓ | | | git-commit | ✓ | | | | | git-config | ✓ | | | | | git-diff | ✓ | | | | | git-rebase | ✓ | | | | +| gleam | ✓ | | | | | glsl | ✓ | | ✓ | | | go | ✓ | ✓ | ✓ | `gopls` | | graphql | ✓ | | | | | haskell | ✓ | | | `haskell-language-server-wrapper` | +| hcl | ✓ | | ✓ | `terraform-ls` | | html | ✓ | | | | | iex | ✓ | | | | | java | ✓ | | | | | javascript | ✓ | | ✓ | `typescript-language-server` | | json | ✓ | | ✓ | | | julia | ✓ | | | `julia` | +| kotlin | ✓ | | | `kotlin-language-server` | | latex | ✓ | | | | | lean | ✓ | | | `lean` | | ledger | ✓ | | | | @@ -40,6 +43,7 @@ | nix | ✓ | | ✓ | `rnix-lsp` | | ocaml | ✓ | | ✓ | | | ocaml-interface | ✓ | | | | +| org | ✓ | | | | | perl | ✓ | ✓ | ✓ | | | php | ✓ | ✓ | ✓ | | | prolog | | | | `swipl` | @@ -51,6 +55,7 @@ | ruby | ✓ | | ✓ | `solargraph` | | rust | ✓ | ✓ | ✓ | `rust-analyzer` | | scala | ✓ | | ✓ | `metals` | +| solidity | ✓ | | | `solc` | | svelte | ✓ | | ✓ | `svelteserver` | | tablegen | ✓ | ✓ | ✓ | | | toml | ✓ | | | | diff --git a/book/src/generated/typable-cmd.md b/book/src/generated/typable-cmd.md index 04e5fae31..1404c168b 100644 --- a/book/src/generated/typable-cmd.md +++ b/book/src/generated/typable-cmd.md @@ -5,6 +5,10 @@ | `:open`, `:o` | Open a file from disk into the current view. | | `:buffer-close`, `:bc`, `:bclose` | Close the current buffer. | | `:buffer-close!`, `:bc!`, `:bclose!` | Close the current buffer forcefully (ignoring unsaved changes). | +| `:buffer-close-others`, `:bco`, `:bcloseother` | Close all buffers but the currently focused one. | +| `:buffer-close-others!`, `:bco!`, `:bcloseother!` | Close all buffers but the currently focused one. | +| `:buffer-close-all`, `:bca`, `:bcloseall` | Close all buffers, without quiting. | +| `:buffer-close-all!`, `:bca!`, `:bcloseall!` | Close all buffers forcefully (ignoring unsaved changes), without quiting. | | `:write`, `:w` | Write changes to disk. Accepts an optional path (:write some/path.txt) | | `:new`, `:n` | Create a new scratch buffer. | | `:format`, `:fmt` | Format the file using the LSP formatter. | @@ -42,11 +46,15 @@ | `:debug-remote`, `:dbg-tcp` | Connect to a debug adapter by TCP address and start a debugging session from a given template with given parameters. | | `:debug-eval` | Evaluate expression in current debug context. | | `:vsplit`, `:vs` | Open the file in a vertical split. | +| `:vsplit-new`, `:vnew` | Open a scratch buffer in a vertical split. | | `:hsplit`, `:hs`, `:sp` | Open the file in a horizontal split. | +| `:hsplit-new`, `:hnew` | Open a scratch buffer in a horizontal split. | | `:tutor` | Open the tutorial. | | `:goto`, `:g` | Go to line number. | | `:set-option`, `:set` | Set a config option at runtime | | `:sort` | Sort ranges in selection. | | `:rsort` | Sort ranges in selection in reverse order. | | `:tree-sitter-subtree`, `:ts-subtree` | Display tree sitter subtree under cursor, primarily for debugging queries. | +| `:config-reload` | Refreshes helix's config. | +| `:config-open` | Open the helix config.toml file. | | `:help`, `:h` | Open documentation for a command or keybind. | diff --git a/book/src/guides/adding_languages.md b/book/src/guides/adding_languages.md index 5844a48ee..e5fa456c6 100644 --- a/book/src/guides/adding_languages.md +++ b/book/src/guides/adding_languages.md @@ -1,45 +1,68 @@ # Adding languages -## Submodules +## Language configuration -To add a new language, you should first add a tree-sitter submodule. To do this, -you can run the command -```sh -git submodule add -f helix-syntax/languages/tree-sitter- -``` -For example, to add tree-sitter-ocaml you would run -```sh -git submodule add -f https://github.com/tree-sitter/tree-sitter-ocaml helix-syntax/languages/tree-sitter-ocaml +To add a new language, you need to add a `language` entry to the +[`languages.toml`][languages.toml] found in the root of the repository; +this `languages.toml` file is included at compilation time, and is +distinct from the `languages.toml` file in the user's [configuration +directory](../configuration.md). + +```toml +[[language]] +name = "mylang" +scope = "scope.mylang" +injection-regex = "^mylang$" +file-types = ["mylang", "myl"] +comment-token = "#" +indent = { tab-width = 2, unit = " " } ``` -Make sure the submodule is shallow by doing -```sh -git config -f .gitmodules submodule.helix-syntax/languages/tree-sitter-.shallow true + +These are the available keys and descriptions for the file. + +| Key | Description | +| ---- | ----------- | +| `name` | The name of the language | +| `scope` | A string like `source.js` that identifies the language. Currently, we strive to match the scope names used by popular TextMate grammars and by the Linguist library. Usually `source.` or `text.` in case of markup languages | +| `injection-regex` | regex pattern that will be tested against a language name in order to determine whether this language should be used for a potential [language injection][treesitter-language-injection] site. | +| `file-types` | The filetypes of the language, for example `["yml", "yaml"]`. Extensions and full file names are supported. | +| `shebangs` | The interpreters from the shebang line, for example `["sh", "bash"]` | +| `roots` | A set of marker files to look for when trying to find the workspace root. For example `Cargo.lock`, `yarn.lock` | +| `auto-format` | Whether to autoformat this language when saving | +| `diagnostic-severity` | Minimal severity of diagnostic for it to be displayed. (Allowed values: `Error`, `Warning`, `Info`, `Hint`) | +| `comment-token` | The token to use as a comment-token | +| `indent` | The indent to use. Has sub keys `tab-width` and `unit` | +| `config` | Language server configuration | +| `grammar` | The tree-sitter grammar to use (defaults to the value of `name`) | + +## Grammar configuration + +If a tree-sitter grammar is available for the language, add a new `grammar` +entry to `languages.toml`. + +```toml +[[grammar]] +name = "mylang" +source = { git = "https://github.com/example/mylang", rev = "a250c4582510ff34767ec3b7dcdd3c24e8c8aa68" } ``` -or you can manually add `shallow = true` to `.gitmodules`. +Grammar configuration takes these keys: -## languages.toml +| Key | Description | +| --- | ----------- | +| `name` | The name of the tree-sitter grammar | +| `source` | The method of fetching the grammar - a table with a schema defined below | -Next, you need to add the language to the [`languages.toml`][languages.toml] found in the root of -the repository; this `languages.toml` file is included at compilation time, and -is distinct from the `language.toml` file in the user's [configuration -directory](../configuration.md). +Where `source` is a table with either these keys when using a grammar from a +git repository: -These are the available keys and descriptions for the file. +| Key | Description | +| --- | ----------- | +| `git` | A git remote URL from which the grammar should be cloned | +| `rev` | The revision (commit hash or tag) which should be fetched | +| `subpath` | A path within the grammar directory which should be built. Some grammar repositories host multiple grammars (for example `tree-sitter-typescript` and `tree-sitter-ocaml`) in subdirectories. This key is used to point `hx --grammar build` to the correct path for compilation. When omitted, the root of repository is used | -| Key | Description | -| ---- | ----------- | -| name | The name of the language | -| scope | A string like `source.js` that identifies the language. Currently, we strive to match the scope names used by popular TextMate grammars and by the Linguist library. Usually `source.` or `text.` in case of markup languages | -| injection-regex | regex pattern that will be tested against a language name in order to determine whether this language should be used for a potential [language injection][treesitter-language-injection] site. | -| file-types | The filetypes of the language, for example `["yml", "yaml"]` | -| shebangs | The interpreters from the shebang line, for example `["sh", "bash"]` | -| roots | A set of marker files to look for when trying to find the workspace root. For example `Cargo.lock`, `yarn.lock` | -| auto-format | Whether to autoformat this language when saving | -| diagnostic-severity | Minimal severity of diagnostic for it to be displayed. (Allowed values: `Error`, `Warning`, `Info`, `Hint`) | -| comment-token | The token to use as a comment-token | -| indent | The indent to use. Has sub keys `tab-width` and `unit` | -| config | Language server configuration | +Or a `path` key with an absolute path to a locally available grammar directory. ## Queries @@ -51,18 +74,14 @@ gives more info on how to write queries. > NOTE: When evaluating queries, the first matching query takes precedence, which is different from other editors like neovim where -the last matching query supercedes the ones before it. See +the last matching query supersedes the ones before it. See [this issue][neovim-query-precedence] for an example. ## Common Issues -- If you get errors when building after switching branches, you may have to remove or update tree-sitter submodules. You can update submodules by running - ```sh - git submodule sync; git submodule update --init - ``` -- Make sure to not use the `--remote` flag. To remove submodules look inside the `.gitmodules` and remove directories that are not present inside of it. +- If you get errors when running after switching branches, you may have to update the tree-sitter grammars. Run `hx --grammar fetch` to fetch the grammars and `hx --grammar build` to build any out-of-date grammars. -- If a parser is segfaulting or you want to remove the parser, make sure to remove the submodule *and* the compiled parser in `runtime/grammar/.so` +- If a parser is segfaulting or you want to remove the parser, make sure to remove the compiled parser in `runtime/grammar/.so` - The indents query is `indents.toml`, *not* `indents.scm`. See [this](https://github.com/helix-editor/helix/issues/114) issue for more information. diff --git a/book/src/guides/indent.md b/book/src/guides/indent.md new file mode 100644 index 000000000..235a30c44 --- /dev/null +++ b/book/src/guides/indent.md @@ -0,0 +1,79 @@ +# Adding Indent Queries + +Helix uses tree-sitter to correctly indent new lines. This requires +a tree-sitter grammar and an `indent.scm` query file placed in +`runtime/queries/{language}/indents.scm`. The indentation for a line +is calculated by traversing the syntax tree from the lowest node at the +beginning of the new line. Each of these nodes contributes to the total +indent when it is captured by the query (in what way depends on the name +of the capture). + +Note that it matters where these added indents begin. For example, +multiple indent level increases that start on the same line only increase +the total indent level by 1. + +## Scopes + +Added indents don't always apply to the whole node. For example, in most +cases when a node should be indented, we actually only want everything +except for its first line to be indented. For this, there are several +scopes (more scopes may be added in the future if required): + +- `all`: +This scope applies to the whole captured node. This is only different from +`tail` when the captured node is the first node on its line. + +- `tail`: +This scope applies to everything except for the first line of the +captured node. + +Every capture type has a default scope which should do the right thing +in most situations. When a different scope is required, this can be +changed by using a `#set!` declaration anywhere in the pattern: +```scm +(assignment_expression + right: (_) @indent + (#set! "scope" "all")) +``` + +## Capture Types + +- `@indent` (default scope `tail`): +Increase the indent level by 1. Multiple occurences in the same line +don't stack. If there is at least one `@indent` and one `@outdent` +capture on the same line, the indent level isn't changed at all. + +- `@outdent` (default scope `all`): +Decrease the indent level by 1. The same rules as for `@indent` apply. + +## Predicates + +In some cases, an S-expression cannot express exactly what pattern should be matched. +For that, tree-sitter allows for predicates to appear anywhere within a pattern, +similar to how `#set!` declarations work: +```scm +(some_kind + (child_kind) @indent + (#predicate? arg1 arg2 ...) +) +``` +The number of arguments depends on the predicate that's used. +Each argument is either a capture (`@name`) or a string (`"some string"`). +The following predicates are supported by tree-sitter: + +- `#eq?`/`#not-eq?`: +The first argument (a capture) must/must not be equal to the second argument +(a capture or a string). + +- `#match?`/`#not-match?`: +The first argument (a capture) must/must not match the regex given in the +second argument (a string). + +Additionally, we support some custom predicates for indent queries: + +- `#not-kind-eq?`: +The kind of the first argument (a capture) must not be equal to the second +argument (a string). + +- `#same-line?`/`#not-same-line?`: +The captures given by the 2 arguments must/must not start on the same line. diff --git a/book/src/guides/textobject.md b/book/src/guides/textobject.md index 7200a5144..cccd4bbf0 100644 --- a/book/src/guides/textobject.md +++ b/book/src/guides/textobject.md @@ -21,6 +21,8 @@ The following [captures][tree-sitter-captures] are recognized: | `class.inside` | | `class.around` | | `parameter.inside` | +| `comment.inside` | +| `comment.around` | [Example query files][textobject-examples] can be found in the helix GitHub repository. diff --git a/book/src/install.md b/book/src/install.md index 1a5a9daa9..b3d42aaf4 100644 --- a/book/src/install.md +++ b/book/src/install.md @@ -19,7 +19,12 @@ brew install helix A [flake](https://nixos.wiki/wiki/Flakes) containing the package is available in the project root. The flake can also be used to spin up a reproducible development -shell for working on Helix. +shell for working on Helix with `nix develop`. + +Flake outputs are cached for each push to master using +[Cachix](https://www.cachix.org/). With Cachix +[installed](https://docs.cachix.org/installation), `cachix use helix` will +configure Nix to use cached outputs when possible. ### Arch Linux @@ -39,7 +44,7 @@ sudo dnf install helix ## Build from source ``` -git clone --recurse-submodules --shallow-submodules -j8 https://github.com/helix-editor/helix +git clone https://github.com/helix-editor/helix cd helix cargo install --path helix-term ``` @@ -49,3 +54,9 @@ This will install the `hx` binary to `$HOME/.cargo/bin`. Helix also needs it's runtime files so make sure to copy/symlink the `runtime/` directory into the config directory (for example `~/.config/helix/runtime` on Linux/macOS). This location can be overriden via the `HELIX_RUNTIME` environment variable. + +## Building tree-sitter grammars + +Tree-sitter grammars must be fetched and compiled if not pre-packaged. +Fetch grammars with `hx --grammar fetch` (requires `git`) and compile them +with `hx --grammar build` (requires a C compiler). diff --git a/book/src/keymap.md b/book/src/keymap.md index 5de4edf93..374513be0 100644 --- a/book/src/keymap.md +++ b/book/src/keymap.md @@ -9,42 +9,33 @@ > NOTE: Unlike vim, `f`, `F`, `t` and `T` are not confined to the current line. -| Key | Description | Command | -| ----- | ----------- | ------- | -| `h`/`Left` | Move left | `move_char_left` | -| `j`/`Down` | Move down | `move_line_down` | -| `k`/`Up` | Move up | `move_line_up` | -| `l`/`Right` | Move right | `move_char_right` | -| `w` | Move next word start | `move_next_word_start` | -| `b` | Move previous word start | `move_prev_word_start` | -| `e` | Move next word end | `move_next_word_end` | -| `W` | Move next WORD start | `move_next_long_word_start` | -| `B` | Move previous WORD start | `move_prev_long_word_start` | -| `E` | Move next WORD end | `move_next_long_word_end` | -| `t` | Find 'till next char | `find_till_char` | -| `f` | Find next char | `find_next_char` | -| `T` | Find 'till previous char | `till_prev_char` | -| `F` | Find previous char | `find_prev_char` | -| `G` | Go to line number `` | `goto_line` | -| `Alt-.` | Repeat last motion (`f`, `t` or `m`) | `repeat_last_motion` | -| `Alt-:` | Ensures the selection is in forward direction | `ensure_selections_forward` | -| `Home` | Move to the start of the line | `goto_line_start` | -| `End` | Move to the end of the line | `goto_line_end` | -| `PageUp` | Move page up | `page_up` | -| `PageDown` | Move page down | `page_down` | -| `Ctrl-u` | Move half page up | `half_page_up` | -| `Ctrl-d` | Move half page down | `half_page_down` | -| `Ctrl-i` | Jump forward on the jumplist | `jump_forward` | -| `Ctrl-o` | Jump backward on the jumplist | `jump_backward` | -| `Ctrl-s` | Save the current selection to the jumplist | `save_selection` | -| `v` | Enter [select (extend) mode](#select--extend-mode) | `select_mode` | -| `g` | Enter [goto mode](#goto-mode) | N/A | -| `m` | Enter [match mode](#match-mode) | N/A | -| `:` | Enter command mode | `command_mode` | -| `z` | Enter [view mode](#view-mode) | N/A | -| `Z` | Enter sticky [view mode](#view-mode) | N/A | -| `Ctrl-w` | Enter [window mode](#window-mode) | N/A | -| `Space` | Enter [space mode](#space-mode) | N/A | +| Key | Description | Command | +| ----- | ----------- | ------- | +| `h`, `Left` | Move left | `move_char_left` | +| `j`, `Down` | Move down | `move_line_down` | +| `k`, `Up` | Move up | `move_line_up` | +| `l`, `Right` | Move right | `move_char_right` | +| `w` | Move next word start | `move_next_word_start` | +| `b` | Move previous word start | `move_prev_word_start` | +| `e` | Move next word end | `move_next_word_end` | +| `W` | Move next WORD start | `move_next_long_word_start` | +| `B` | Move previous WORD start | `move_prev_long_word_start` | +| `E` | Move next WORD end | `move_next_long_word_end` | +| `t` | Find 'till next char | `find_till_char` | +| `f` | Find next char | `find_next_char` | +| `T` | Find 'till previous char | `till_prev_char` | +| `F` | Find previous char | `find_prev_char` | +| `G` | Go to line number `` | `goto_line` | +| `Alt-.` | Repeat last motion (`f`, `t` or `m`) | `repeat_last_motion` | +| `Home` | Move to the start of the line | `goto_line_start` | +| `End` | Move to the end of the line | `goto_line_end` | +| `Ctrl-b`, `PageUp` | Move page up | `page_up` | +| `Ctrl-f`, `PageDown` | Move page down | `page_down` | +| `Ctrl-u` | Move half page up | `half_page_up` | +| `Ctrl-d` | Move half page down | `half_page_down` | +| `Ctrl-i` | Jump forward on the jumplist | `jump_forward` | +| `Ctrl-o` | Jump backward on the jumplist | `jump_backward` | +| `Ctrl-s` | Save the current selection to the jumplist | `save_selection` | ### Changes @@ -84,48 +75,50 @@ #### Shell -| Key | Description | Command | -| ------ | ----------- | ------- | -| | | Pipe each selection through shell command, replacing with output | `shell_pipe` | -| Alt-| | Pipe each selection into shell command, ignoring output | `shell_pipe_to` | -| `!` | Run shell command, inserting output before each selection | `shell_insert_output` | -| `Alt-!` | Run shell command, appending output after each selection | `shell_append_output` | +| Key | Description | Command | +| ------ | ----------- | ------- | +| | | Pipe each selection through shell command, replacing with output | `shell_pipe` | +| Alt-| | Pipe each selection into shell command, ignoring output | `shell_pipe_to` | +| `!` | Run shell command, inserting output before each selection | `shell_insert_output` | +| `Alt-!` | Run shell command, appending output after each selection | `shell_append_output` | | `$` | Pipe each selection into shell command, keep selections where command returned 0 | `shell_keep_pipe` | ### Selection manipulation -| Key | Description | Command | -| ----- | ----------- | ------- | -| `s` | Select all regex matches inside selections | `select_regex` | -| `S` | Split selection into subselections on regex matches | `split_selection` | -| `Alt-s` | Split selection on newlines | `split_selection_on_newline` | -| `&` | Align selection in columns | `align_selections` | -| `_` | Trim whitespace from the selection | `trim_selections` | -| `;` | Collapse selection onto a single cursor | `collapse_selection` | -| `Alt-;` | Flip selection cursor and anchor | `flip_selections` | -| `,` | Keep only the primary selection | `keep_primary_selection` | -| `Alt-,` | Remove the primary selection | `remove_primary_selection` | -| `C` | Copy selection onto the next line (Add cursor below) | `copy_selection_on_next_line` | -| `Alt-C` | Copy selection onto the previous line (Add cursor above) | `copy_selection_on_prev_line` | -| `(` | Rotate main selection backward | `rotate_selections_backward` | -| `)` | Rotate main selection forward | `rotate_selections_forward` | -| `Alt-(` | Rotate selection contents backward | `rotate_selection_contents_backward` | -| `Alt-)` | Rotate selection contents forward | `rotate_selection_contents_forward` | -| `%` | Select entire file | `select_all` | -| `x` | Select current line, if already selected, extend to next line | `extend_line` | -| `X` | Extend selection to line bounds (line-wise selection) | `extend_to_line_bounds` | -| `J` | Join lines inside selection | `join_selections` | -| `K` | Keep selections matching the regex | `keep_selections` | -| `Alt-K` | Remove selections matching the regex | `remove_selections` | -| `Ctrl-c` | Comment/uncomment the selections | `toggle_comments` | -| `Alt-k` | Expand selection to parent syntax node (**TS**) | `expand_selection` | -| `Alt-j` | Shrink syntax tree object selection (**TS**) | `shrink_selection` | -| `Alt-h` | Select previous sibling node in syntax tree (**TS**) | `select_prev_sibling` | -| `Alt-l` | Select next sibling node in syntax tree (**TS**) | `select_next_sibling` | +| Key | Description | Command | +| ----- | ----------- | ------- | +| `s` | Select all regex matches inside selections | `select_regex` | +| `S` | Split selection into subselections on regex matches | `split_selection` | +| `Alt-s` | Split selection on newlines | `split_selection_on_newline` | +| `&` | Align selection in columns | `align_selections` | +| `_` | Trim whitespace from the selection | `trim_selections` | +| `;` | Collapse selection onto a single cursor | `collapse_selection` | +| `Alt-;` | Flip selection cursor and anchor | `flip_selections` | +| `Alt-:` | Ensures the selection is in forward direction | `ensure_selections_forward` | +| `,` | Keep only the primary selection | `keep_primary_selection` | +| `Alt-,` | Remove the primary selection | `remove_primary_selection` | +| `C` | Copy selection onto the next line (Add cursor below) | `copy_selection_on_next_line` | +| `Alt-C` | Copy selection onto the previous line (Add cursor above) | `copy_selection_on_prev_line` | +| `(` | Rotate main selection backward | `rotate_selections_backward` | +| `)` | Rotate main selection forward | `rotate_selections_forward` | +| `Alt-(` | Rotate selection contents backward | `rotate_selection_contents_backward` | +| `Alt-)` | Rotate selection contents forward | `rotate_selection_contents_forward` | +| `%` | Select entire file | `select_all` | +| `x` | Select current line, if already selected, extend to next line | `extend_line` | +| `X` | Extend selection to line bounds (line-wise selection) | `extend_to_line_bounds` | +| `J` | Join lines inside selection | `join_selections` | +| `K` | Keep selections matching the regex | `keep_selections` | +| `Alt-K` | Remove selections matching the regex | `remove_selections` | +| `Ctrl-c` | Comment/uncomment the selections | `toggle_comments` | +| `Alt-k`, `Alt-up` | Expand selection to parent syntax node (**TS**) | `expand_selection` | +| `Alt-j`, `Alt-down` | Shrink syntax tree object selection (**TS**) | `shrink_selection` | +| `Alt-h`, `Alt-left` | Select previous sibling node in syntax tree (**TS**) | `select_prev_sibling` | +| `Alt-l`, `Alt-right` | Select next sibling node in syntax tree (**TS**) | `select_next_sibling` | ### Search +Search commands all operate on the `/` register by default. Use `"` to operate on a different one. | Key | Description | Command | | ----- | ----------- | ------- | @@ -139,6 +132,17 @@ These sub-modes are accessible from normal mode and typically switch back to normal mode after a command. +| Key | Description | Command | +| ----- | ----------- | ------- | +| `v` | Enter [select (extend) mode](#select--extend-mode) | `select_mode` | +| `g` | Enter [goto mode](#goto-mode) | N/A | +| `m` | Enter [match mode](#match-mode) | N/A | +| `:` | Enter command mode | `command_mode` | +| `z` | Enter [view mode](#view-mode) | N/A | +| `Z` | Enter sticky [view mode](#view-mode) | N/A | +| `Ctrl-w` | Enter [window mode](#window-mode) | N/A | +| `Space` | Enter [space mode](#space-mode) | N/A | + #### View mode View mode is intended for scrolling and manipulating the view without changing @@ -147,18 +151,18 @@ key to return to normal mode after usage (useful when you're simply looking over text and not actively editing it). -| Key | Description | Command | -| ----- | ----------- | ------- | -| `z` , `c` | Vertically center the line | `align_view_center` | -| `t` | Align the line to the top of the screen | `align_view_top` | -| `b` | Align the line to the bottom of the screen | `align_view_bottom` | -| `m` | Align the line to the middle of the screen (horizontally) | `align_view_middle` | -| `j` , `down` | Scroll the view downwards | `scroll_down` | -| `k` , `up` | Scroll the view upwards | `scroll_up` | -| `Ctrl-f` | Move page down | `page_down` | -| `Ctrl-b` | Move page up | `page_up` | -| `Ctrl-d` | Move half page down | `half_page_down` | -| `Ctrl-u` | Move half page up | `half_page_up` | +| Key | Description | Command | +| ----- | ----------- | ------- | +| `z`, `c` | Vertically center the line | `align_view_center` | +| `t` | Align the line to the top of the screen | `align_view_top` | +| `b` | Align the line to the bottom of the screen | `align_view_bottom` | +| `m` | Align the line to the middle of the screen (horizontally) | `align_view_middle` | +| `j`, `down` | Scroll the view downwards | `scroll_down` | +| `k`, `up` | Scroll the view upwards | `scroll_up` | +| `Ctrl-f`, `PageDown` | Move page down | `page_down` | +| `Ctrl-b`, `PageUp` | Move page up | `page_up` | +| `Ctrl-d` | Move half page down | `half_page_down` | +| `Ctrl-u` | Move half page up | `half_page_up` | #### Goto mode @@ -206,19 +210,19 @@ TODO: Mappings for selecting syntax nodes (a superset of `[`). This layer is similar to vim keybindings as kakoune does not support window. -| Key | Description | Command | -| ----- | ------------- | ------- | -| `w`, `Ctrl-w` | Switch to next window | `rotate_view` | -| `v`, `Ctrl-v` | Vertical right split | `vsplit` | -| `s`, `Ctrl-s` | Horizontal bottom split | `hsplit` | -| `h`, `Ctrl-h`, `left` | Move to left split | `jump_view_left` | -| `f` | Go to files in the selection in horizontal splits | `goto_file` | -| `F` | Go to files in the selection in vertical splits | `goto_file` | -| `j`, `Ctrl-j`, `down` | Move to split below | `jump_view_down` | -| `k`, `Ctrl-k`, `up` | Move to split above | `jump_view_up` | -| `l`, `Ctrl-l`, `right` | Move to right split | `jump_view_right` | -| `q`, `Ctrl-q` | Close current window | `wclose` | -| `o`, `Ctrl-o` | Only keep the current window, closing all the others | `wonly` | +| Key | Description | Command | +| ----- | ------------- | ------- | +| `w`, `Ctrl-w` | Switch to next window | `rotate_view` | +| `v`, `Ctrl-v` | Vertical right split | `vsplit` | +| `s`, `Ctrl-s` | Horizontal bottom split | `hsplit` | +| `f` | Go to files in the selection in horizontal splits | `goto_file` | +| `F` | Go to files in the selection in vertical splits | `goto_file` | +| `h`, `Ctrl-h`, `Left` | Move to left split | `jump_view_left` | +| `j`, `Ctrl-j`, `Down` | Move to split below | `jump_view_down` | +| `k`, `Ctrl-k`, `Up` | Move to split above | `jump_view_up` | +| `l`, `Ctrl-l`, `Right` | Move to right split | `jump_view_right` | +| `q`, `Ctrl-q` | Close current window | `wclose` | +| `o`, `Ctrl-o` | Only keep the current window, closing all the others | `wonly` | #### Space mode @@ -242,6 +246,7 @@ This layer is a kludge of mappings, mostly pickers. | `Y` | Yank main selection to clipboard | `yank_main_selection_to_clipboard` | | `R` | Replace selections by clipboard contents | `replace_selections_with_clipboard` | | `/` | Global search in workspace folder | `global_search` | +| `?` | Open command palette | `command_palette` | > TIP: Global search displays results in a fuzzy picker, use `space + '` to bring it back up after opening a file. @@ -268,40 +273,57 @@ Mappings in the style of [vim-unimpaired](https://github.com/tpope/vim-unimpaire | `[f` | Go to previous function (**TS**) | `goto_prev_function` | | `]c` | Go to next class (**TS**) | `goto_next_class` | | `[c` | Go to previous class (**TS**) | `goto_prev_class` | -| `]p` | Go to next parameter (**TS**) | `goto_next_parameter` | -| `[p` | Go to previous parameter (**TS**) | `goto_prev_parameter` | +| `]a` | Go to next argument/parameter (**TS**) | `goto_next_parameter` | +| `[a` | Go to previous argument/parameter (**TS**) | `goto_prev_parameter` | +| `]o` | Go to next comment (**TS**) | `goto_next_comment` | +| `[o` | Go to previous comment (**TS**) | `goto_prev_comment` | | `[space` | Add newline above | `add_newline_above` | | `]space` | Add newline below | `add_newline_below` | ## Insert Mode -| Key | Description | Command | -| ----- | ----------- | ------- | -| `Escape` | Switch to normal mode | `normal_mode` | -| `Ctrl-x` | Autocomplete | `completion` | -| `Ctrl-r` | Insert a register content | `insert_register` | -| `Ctrl-w` | Delete previous word | `delete_word_backward` | -| `Alt-d` | Delete next word | `delete_word_forward` | -| `Alt-b`, `Alt-Left` | Backward a word | `move_prev_word_end` | -| `Ctrl-b`, `Left` | Backward a char | `move_char_left` | -| `Alt-f`, `Alt-Right` | Forward a word | `move_next_word_start` | -| `Ctrl-f`, `Right` | Forward a char | `move_char_right` | -| `Ctrl-e`, `End` | move to line end | `goto_line_end_newline` | -| `Ctrl-a`, `Home` | move to line start | `goto_line_start` | -| `Ctrl-u` | delete to start of line | `kill_to_line_start` | -| `Ctrl-k` | delete to end of line | `kill_to_line_end` | -| `backspace`, `Ctrl-h` | delete previous char | `delete_char_backward` | -| `delete`, `Ctrl-d` | delete previous char | `delete_char_forward` | -| `Ctrl-p`, `Up` | move to previous line | `move_line_up` | -| `Ctrl-n`, `Down` | move to next line | `move_line_down` | +We support many readline/emacs style bindings in insert mode for +convenience. These can be helpful for making simple modifications +without escaping to normal mode, but beware that you will not have an +undo-able "save point" until you return to normal mode. + +| Key | Description | Command | +| ----- | ----------- | ------- | +| `Escape` | Switch to normal mode | `normal_mode` | +| `Ctrl-x` | Autocomplete | `completion` | +| `Ctrl-r` | Insert a register content | `insert_register` | +| `Ctrl-w`, `Alt-Backspace` | Delete previous word | `delete_word_backward` | +| `Alt-d` | Delete next word | `delete_word_forward` | +| `Alt-b`, `Alt-Left` | Backward a word | `move_prev_word_end` | +| `Ctrl-b`, `Left` | Backward a char | `move_char_left` | +| `Alt-f`, `Alt-Right` | Forward a word | `move_next_word_start` | +| `Ctrl-f`, `Right` | Forward a char | `move_char_right` | +| `Ctrl-e`, `End` | Move to line end | `goto_line_end_newline` | +| `Ctrl-a`, `Home` | Move to line start | `goto_line_start` | +| `Ctrl-u` | Delete to start of line | `kill_to_line_start` | +| `Ctrl-k` | Delete to end of line | `kill_to_line_end` | +| `Ctrl-j`, `Enter` | Insert new line | `insert_newline` | +| `Backspace`, `Ctrl-h` | Delete previous char | `delete_char_backward` | +| `Delete`, `Ctrl-d` | Delete previous char | `delete_char_forward` | +| `Ctrl-p`, `Up` | Move to previous line | `move_line_up` | +| `Ctrl-n`, `Down` | Move to next line | `move_line_down` | +| `PageUp` | Move one page up | `page_up` | +| `PageDown` | Move one page down | `page_down` | +| `Alt->` | Go to end of buffer | `goto_file_end` | +| `Alt-<` | Go to start of buffer | `goto_file_start` | ## Select / extend mode -I'm still pondering whether to keep this mode or not. It changes movement -commands (including goto) to extend the existing selection instead of replacing it. +This mode echoes Normal mode, but changes any movements to extend +selections rather than replace them. Goto motions are also changed to +extend, so that `vgl` for example extends the selection to the end of +the line. -> NOTE: It's a bit confusing at the moment because extend hasn't been -> implemented for all movement commands yet. +Search is also affected. By default, `n` and `N` will remove the current +selection and select the next instance of the search term. Toggling this +mode before pressing `n` or `N` makes it possible to keep the current +selection. Toggling it on and off during your iterative searching allows +you to selectively add search terms to your selections. # Picker diff --git a/book/src/languages.md b/book/src/languages.md index 4c4dc326d..3372a1202 100644 --- a/book/src/languages.md +++ b/book/src/languages.md @@ -4,10 +4,37 @@ Language-specific settings and settings for particular language servers can be c Changes made to the `languages.toml` file in a user's [configuration directory](./configuration.md) are merged with helix's defaults on start-up, such that a user's settings will take precedence over defaults in the event of a collision. For example, the default `languages.toml` sets rust's `auto-format` to `true`. If a user wants to disable auto-format, they can change the `languages.toml` in their [configuration directory](./configuration.md) to make the rust entry read like the example below; the new key/value pair `auto-format = false` will override the default when the two sets of settings are merged on start-up: -``` +```toml # in /helix/languages.toml [[language]] name = "rust" auto-format = false ``` + +## Tree-sitter grammars + +Tree-sitter grammars can also be configured in `languages.toml`: + +```toml +# in /helix/languages.toml + +[[grammar]] +name = "rust" +source = { git = "https://github.com/tree-sitter/tree-sitter-rust", rev = "a250c4582510ff34767ec3b7dcdd3c24e8c8aa68" } + +[[grammar]] +name = "c" +source = { path = "/path/to/tree-sitter-c" } +``` + +You may use a top-level `use-grammars` key to control which grammars are fetched and built. + +```toml +# Note: this key must come **before** the [[language]] and [[grammar]] sections +use-grammars = { only = [ "rust", "c", "cpp" ] } +# or +use-grammars = { except = [ "yaml", "json" ] } +``` + +When omitted, all grammars are fetched and built. diff --git a/book/src/themes.md b/book/src/themes.md index 9abcfe8c1..62265e28b 100644 --- a/book/src/themes.md +++ b/book/src/themes.md @@ -166,6 +166,8 @@ We use a similar set of scopes as - `markup` - `heading` + - `marker` + - `1`, `2`, `3`, `4`, `5`, `6` - heading text for h1 through h6 - `list` - `unnumbered` - `numbered` @@ -216,12 +218,12 @@ These scopes are used for theming the editor interface. | `ui.statusline` | Statusline | | `ui.statusline.inactive` | Statusline (unfocused document) | | `ui.popup` | | +| `ui.popup.info` | | | `ui.window` | | | `ui.help` | | | `ui.text` | | | `ui.text.focus` | | -| `ui.info` | | -| `ui.info.text` | | +| `ui.text.info` | | | `ui.menu` | | | `ui.menu.selected` | | | `ui.selection` | For selections in the editing area | diff --git a/book/src/title-page.md b/book/src/title-page.md new file mode 100644 index 000000000..c182a753c --- /dev/null +++ b/book/src/title-page.md @@ -0,0 +1,15 @@ +# Helix + +Docs for bleeding edge master can be found at +[https://docs.helix-editor.com/master](https://docs.helix-editor.com/master). + +See the [usage] section for a quick overview of the editor, [keymap] +section for all available keybindings and the [configuration] section +for defining custom keybindings, setting themes, etc. + +Refer the [FAQ] for common questions. + +[FAQ]: https://github.com/helix-editor/helix/wiki/FAQ +[usage]: ./usage.md +[keymap]: ./keymap.md +[configuration]: ./configuration.md diff --git a/book/src/usage.md b/book/src/usage.md index 039628bf8..010e30f59 100644 --- a/book/src/usage.md +++ b/book/src/usage.md @@ -68,7 +68,8 @@ Currently supported: `word`, `surround`, `function`, `class`, `parameter`. | `(`, `[`, `'`, etc | Specified surround pairs | | `f` | Function | | `c` | Class | -| `p` | Parameter | +| `a` | Argument/parameter | +| `o` | Comment | > NOTE: `f`, `c`, etc need a tree-sitter grammar active for the current document and a special tree-sitter query file to work properly. [Only diff --git a/docs/architecture.md b/docs/architecture.md index 17ef296dc..33624aac2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,12 +1,13 @@ -| Crate | Description | -| ----------- | ----------- | -| helix-core | Core editing primitives, functional. | -| helix-syntax | Tree-sitter grammars | -| helix-lsp | Language server client | -| helix-view | UI abstractions for use in backends, imperative shell. | -| helix-term | Terminal UI | -| helix-tui | TUI primitives, forked from tui-rs, inspired by Cursive | +| Crate | Description | +| ----------- | ----------- | +| helix-core | Core editing primitives, functional. | +| helix-lsp | Language server client | +| helix-dap | Debug Adapter Protocol (DAP) client | +| helix-loader | Functions for building, fetching, and loading external resources | +| helix-view | UI abstractions for use in backends, imperative shell. | +| helix-term | Terminal UI | +| helix-tui | TUI primitives, forked from tui-rs, inspired by Cursive | This document contains a high-level overview of Helix internals. @@ -54,15 +55,40 @@ A `Document` ties together the `Rope`, `Selection`(s), `Syntax`, document file. A `View` represents an open split in the UI. It holds the currently open -document ID and other related state. +document ID and other related state. Views encapsulate the gutter, status line, +diagnostics, and the inner area where the code is displayed. > NOTE: Multiple views are able to display the same document, so the document > contains selections for each view. To retrieve, `document.selection()` takes > a `ViewId`. +`Info` is the autoinfo box that shows hints when awaiting another key with bindings +like `g` and `m`. It is attached to the viewport as a whole. + +`Surface` is like a buffer to which widgets draw themselves to, and the +surface is then rendered on the screen on each cycle. + +`Rect`s are areas (simply an x and y coordinate with the origin at the +screen top left and then a height and width) which are part of a +`Surface`. They can be used to limit the area to which a `Component` can +render. For example if we wrap a `Markdown` component in a `Popup` +(think the documentation popup with space+k), Markdown's render method +will get a Rect that is the exact size of the popup. + +Widgets are called `Component`s internally, and you can see most of them +in `helix-term/src/ui`. Some components like `Popup` and `Overlay` can take +other components as children. + +`Layer`s are how multiple components are displayed, and is simply a +`Vec`. Layers are managed by the `Compositor`. On each top +level render call, the compositor renders each component in the order +they were pushed into the stack. This makes multiple components "layer" +on top of one another. Hence we get a file picker displayed over the +editor, etc. + The `Editor` holds the global state: all the open documents, a tree -representation of all the view splits, and a registry of language servers. To -open or close files, interact with the editor. +representation of all the view splits, the configuration, and a registry of +language servers. To open or close files, interact with the editor. ## LSP diff --git a/flake.lock b/flake.lock index 94e443e3a..40a87eb55 100644 --- a/flake.lock +++ b/flake.lock @@ -1,12 +1,35 @@ { "nodes": { + "crane": { + "flake": false, + "locked": { + "lastModified": 1644785799, + "narHash": "sha256-VpAJO1L0XeBvtCuNGK4IDKp6ENHIpTrlaZT7yfBCvwo=", + "owner": "ipetkov", + "repo": "crane", + "rev": "fc7a94f841347c88f2cb44217b2a3faa93e2a0b2", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "type": "github" + } + }, "devshell": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": [ + "nixCargoIntegration", + "nixpkgs" + ] + }, "locked": { - "lastModified": 1641980203, - "narHash": "sha256-RiWJ3+6V267Ji+P54K1Xrj1Nsah9BfG/aLfIhqgVyBY=", + "lastModified": 1646667754, + "narHash": "sha256-LahZHvCC3UVzGQ55iWDRZkuDssXl1rYgqgScrPV9S38=", "owner": "numtide", "repo": "devshell", - "rev": "d897c1ddb4eab66cc2b783c7868d78555b9880ad", + "rev": "59fbe1dfc0de8c3332957c16998a7d16dff365d8", "type": "github" }, "original": { @@ -15,7 +38,73 @@ "type": "github" } }, + "dream2nix": { + "inputs": { + "alejandra": [ + "nixCargoIntegration", + "nixpkgs" + ], + "crane": "crane", + "flake-utils-pre-commit": [ + "nixCargoIntegration", + "nixpkgs" + ], + "gomod2nix": [ + "nixCargoIntegration", + "nixpkgs" + ], + "mach-nix": [ + "nixCargoIntegration", + "nixpkgs" + ], + "nixpkgs": [ + "nixCargoIntegration", + "nixpkgs" + ], + "node2nix": [ + "nixCargoIntegration", + "nixpkgs" + ], + "poetry2nix": [ + "nixCargoIntegration", + "nixpkgs" + ], + "pre-commit-hooks": [ + "nixCargoIntegration", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1646710334, + "narHash": "sha256-eLBcDgcbOUfeH4k6SEW5a5v0PTp2KNCn+5ZXIoWGYww=", + "owner": "nix-community", + "repo": "dream2nix", + "rev": "5dcfbfd3b60ce0208b894c1bdea00e2bdf80ca6a", + "type": "github" + }, + "original": { + "owner": "nix-community", + "ref": "main", + "repo": "dream2nix", + "type": "github" + } + }, "flake-utils": { + "locked": { + "lastModified": 1642700792, + "narHash": "sha256-XqHrk7hFb+zBvRg6Ghl+AZDq03ov6OshJLiSWOoX5es=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "846b2ae0fc4cc943637d3d1def4454213e203cba", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_2": { "locked": { "lastModified": 1637014545, "narHash": "sha256-26IZAc5yzlD9FlDT54io1oqG/bBoyka+FJk5guaX4x4=", @@ -33,6 +122,7 @@ "nixCargoIntegration": { "inputs": { "devshell": "devshell", + "dream2nix": "dream2nix", "nixpkgs": [ "nixpkgs" ], @@ -41,11 +131,11 @@ ] }, "locked": { - "lastModified": 1642054253, - "narHash": "sha256-kHh9VmaB7gbS6pheheC4x0uT84LEmhfbsbWEQJgU2E4=", + "lastModified": 1646766572, + "narHash": "sha256-DV3+zxvAIKsMHsHedJKYFsracvFyLKpFQqurUBR86oY=", "owner": "yusdacra", "repo": "nix-cargo-integration", - "rev": "f8fa9af990195a3f63fe2dde84aa187e193da793", + "rev": "3a3f47f43ba486b7554164a698c8dfc5a38624ce", "type": "github" }, "original": { @@ -56,11 +146,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1641887635, - "narHash": "sha256-kDGpufwzVaiGe5e1sBUBPo9f1YN+nYHJlYqCaVpZTQQ=", + "lastModified": 1646497237, + "narHash": "sha256-Ccpot1h/rV8MgcngDp5OrdmLTMaUTbStZTR5/sI7zW0=", "owner": "nixos", "repo": "nixpkgs", - "rev": "b2737d4980a17cc2b7d600d7d0b32fd7333aca88", + "rev": "062a0c5437b68f950b081bbfc8a699d57a4ee026", "type": "github" }, "original": { @@ -70,22 +160,6 @@ "type": "github" } }, - "nixpkgs_2": { - "locked": { - "lastModified": 1637453606, - "narHash": "sha256-Gy6cwUswft9xqsjWxFYEnx/63/qzaFUwatcbV5GF/GQ=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "8afc4e543663ca0a6a4f496262cd05233737e732", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixpkgs-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, "root": { "inputs": { "nixCargoIntegration": "nixCargoIntegration", @@ -95,15 +169,17 @@ }, "rust-overlay": { "inputs": { - "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs_2" + "flake-utils": "flake-utils_2", + "nixpkgs": [ + "nixpkgs" + ] }, "locked": { - "lastModified": 1642128126, - "narHash": "sha256-av8JUACdrTfQYl/ftZJvKpZEmZfa0avCq7tt5Usdoq0=", + "lastModified": 1646792695, + "narHash": "sha256-2drCXIKIQnJMlTZbcCfuHZAh+iPcdlRkCqtZnA6MHLY=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "ce4ef6f2d74f2b68f7547df1de22d1b0037ce4ad", + "rev": "7f599870402c8d2a5806086c8ee0f2d92b175c54", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 660207eda..38ba9fd0c 100644 --- a/flake.nix +++ b/flake.nix @@ -3,7 +3,10 @@ inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; - rust-overlay.url = "github:oxalica/rust-overlay"; + rust-overlay = { + url = "github:oxalica/rust-overlay"; + inputs.nixpkgs.follows = "nixpkgs"; + }; nixCargoIntegration = { url = "github:yusdacra/nix-cargo-integration"; inputs.nixpkgs.follows = "nixpkgs"; @@ -11,60 +14,37 @@ }; }; - outputs = inputs@{ self, nixCargoIntegration, ... }: + outputs = inputs@{ nixCargoIntegration, ... }: nixCargoIntegration.lib.makeOutputs { root = ./.; - buildPlatform = "crate2nix"; renameOutputs = { "helix-term" = "helix"; }; # Set default app to hx (binary is from helix-term release build) # Set default package to helix-term release build - defaultOutputs = { app = "hx"; package = "helix"; }; + defaultOutputs = { + app = "hx"; + package = "helix"; + }; overrides = { - crateOverrides = common: _: rec { - # link languages and theme toml files since helix-core/helix-view expects them - helix-core = _: { preConfigure = "ln -s ${common.root}/{languages.toml,theme.toml,base16_theme.toml} .."; }; - helix-view = _: { preConfigure = "ln -s ${common.root}/{languages.toml,theme.toml,base16_theme.toml} .."; }; - helix-syntax = prev: { - src = - let - pkgs = common.pkgs; - helix = pkgs.fetchgit { - url = "https://github.com/helix-editor/helix.git"; - rev = "a8fd33ac012a79069ef1409503a2edcf3a585153"; - fetchSubmodules = true; - sha256 = "sha256-5AtOC55ttWT+7RYMboaFxpGZML51ix93wAkYJTt+8JI="; - }; - in - pkgs.runCommand prev.src.name { } '' - mkdir -p $out - ln -s ${prev.src}/* $out - ln -sf ${helix}/helix-syntax/languages $out - ''; - preConfigure = "mkdir -p ../runtime/grammars"; - postInstall = "cp -r ../runtime $out/runtime"; - }; + crateOverrides = common: _: { helix-term = prev: let - inherit (common) pkgs lib; - helixSyntax = lib.buildCrate { - root = self; - memberName = "helix-syntax"; - defaultCrateOverrides = { - helix-syntax = helix-syntax; - }; - release = false; - }; + inherit (common) pkgs; + grammars = pkgs.callPackage ./grammars.nix { }; runtimeDir = pkgs.runCommand "helix-runtime" { } '' mkdir -p $out ln -s ${common.root}/runtime/* $out - ln -sf ${helixSyntax}/runtime/grammars $out + rm -r $out/grammars + ln -s ${grammars} $out/grammars ''; in { + # disable fetching and building of tree-sitter grammars in the helix-term build.rs + HELIX_DISABLE_AUTO_GRAMMAR_BUILD = "1"; # link languages and theme toml files since helix-term expects them (for tests) preConfigure = "ln -s ${common.root}/{languages.toml,theme.toml,base16_theme.toml} .."; buildInputs = (prev.buildInputs or [ ]) ++ [ common.cCompiler.cc.lib ]; nativeBuildInputs = [ pkgs.makeWrapper ]; + postFixup = '' if [ -f "$out/bin/hx" ]; then wrapProgram "$out/bin/hx" --set HELIX_RUNTIME "${runtimeDir}" diff --git a/grammars.nix b/grammars.nix new file mode 100644 index 000000000..ada14aaf9 --- /dev/null +++ b/grammars.nix @@ -0,0 +1,106 @@ +{ stdenv, lib, runCommand, yj }: +let + # HACK: nix < 2.6 has a bug in the toml parser, so we convert to JSON + # before parsing + languages-json = runCommand "languages-toml-to-json" { } '' + ${yj}/bin/yj -t < ${./languages.toml} > $out + ''; + languagesConfig = if lib.versionAtLeast builtins.nixVersion "2.6.0" then + builtins.fromTOML (builtins.readFile ./languages.toml) + else + builtins.fromJSON (builtins.readFile (builtins.toPath languages-json)); + isGitGrammar = (grammar: + builtins.hasAttr "source" grammar && builtins.hasAttr "git" grammar.source + && builtins.hasAttr "rev" grammar.source); + isGitHubGrammar = grammar: lib.hasPrefix "https://github.com" grammar.source.git; + toGitHubFetcher = url: let + match = builtins.match "https://github\.com/([^/]*)/([^/]*)/?" url; + in { + owner = builtins.elemAt match 0; + repo = builtins.elemAt match 1; + }; + gitGrammars = builtins.filter isGitGrammar languagesConfig.grammar; + buildGrammar = grammar: + let + gh = toGitHubFetcher grammar.source.git; + sourceGit = builtins.fetchTree { + type = "git"; + url = grammar.source.git; + rev = grammar.source.rev; + ref = grammar.source.ref or "HEAD"; + shallow = true; + }; + sourceGitHub = builtins.fetchTree { + type = "github"; + owner = gh.owner; + repo = gh.repo; + inherit (grammar.source) rev; + }; + source = if isGitHubGrammar grammar then sourceGitHub else sourceGit; + in stdenv.mkDerivation rec { + # see https://github.com/NixOS/nixpkgs/blob/fbdd1a7c0bc29af5325e0d7dd70e804a972eb465/pkgs/development/tools/parsing/tree-sitter/grammar.nix + + pname = "helix-tree-sitter-${grammar.name}"; + version = grammar.source.rev; + + src = if builtins.hasAttr "subpath" grammar.source then + "${source}/${grammar.source.subpath}" + else + source; + + dontUnpack = true; + dontConfigure = true; + + FLAGS = [ + "-I${src}/src" + "-g" + "-O3" + "-fPIC" + "-fno-exceptions" + "-Wl,-z,relro,-z,now" + ]; + + NAME = grammar.name; + + buildPhase = '' + runHook preBuild + + if [[ -e "$src/src/scanner.cc" ]]; then + $CXX -c "$src/src/scanner.cc" -o scanner.o $FLAGS + elif [[ -e "$src/src/scanner.c" ]]; then + $CC -c "$src/src/scanner.c" -o scanner.o $FLAGS + fi + + $CC -c "$src/src/parser.c" -o parser.o $FLAGS + $CXX -shared -o $NAME.so *.o + + ls -al + + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + mkdir $out + mv $NAME.so $out/ + runHook postInstall + ''; + + # Strip failed on darwin: strip: error: symbols referenced by indirect symbol table entries that can't be stripped + fixupPhase = lib.optionalString stdenv.isLinux '' + runHook preFixup + $STRIP $out/$NAME.so + runHook postFixup + ''; + }; + builtGrammars = builtins.map (grammar: { + inherit (grammar) name; + artifact = buildGrammar grammar; + }) gitGrammars; + grammarLinks = builtins.map (grammar: + "ln -s ${grammar.artifact}/${grammar.name}.so $out/${grammar.name}.so") + builtGrammars; +in runCommand "consolidated-helix-grammars" { } '' + mkdir -p $out + ${builtins.concatStringsSep "\n" grammarLinks} +'' diff --git a/helix-core/Cargo.toml b/helix-core/Cargo.toml index 7ff91cfda..6e019a42e 100644 --- a/helix-core/Cargo.toml +++ b/helix-core/Cargo.toml @@ -11,20 +11,21 @@ homepage = "https://helix-editor.com" include = ["src/**/*", "README.md"] [features] +unicode-lines = ["ropey/unicode_lines"] [dependencies] -helix-syntax = { version = "0.6", path = "../helix-syntax" } +helix-loader = { version = "0.6", path = "../helix-loader" } -ropey = "1.3" +ropey = { version = "1.4", default-features = false } smallvec = "1.8" -smartstring = "0.2.9" +smartstring = "1.0.1" unicode-segmentation = "1.9" unicode-width = "0.1" unicode-general-category = "0.5" # slab = "0.4.2" slotmap = "1.0" tree-sitter = "0.20" -once_cell = "1.9" +once_cell = "1.10" arc-swap = "1" regex = "1" @@ -35,10 +36,11 @@ toml = "0.5" similar = "2.1" -etcetera = "0.3" encoding_rs = "0.8" chrono = { version = "0.4", default-features = false, features = ["alloc", "std"] } +etcetera = "0.3" + [dev-dependencies] quickcheck = { version = "1", default-features = false } diff --git a/helix-core/src/auto_pairs.rs b/helix-core/src/auto_pairs.rs index f4359a342..bcd47356f 100644 --- a/helix-core/src/auto_pairs.rs +++ b/helix-core/src/auto_pairs.rs @@ -4,12 +4,14 @@ use crate::{ graphemes, movement::Direction, Range, Rope, RopeGraphemes, Selection, Tendril, Transaction, }; +use std::collections::HashMap; + use log::debug; use smallvec::SmallVec; // Heavily based on https://github.com/codemirror/closebrackets/ -pub const PAIRS: &[(char, char)] = &[ +pub const DEFAULT_PAIRS: &[(char, char)] = &[ ('(', ')'), ('{', '}'), ('[', ']'), @@ -18,9 +20,95 @@ pub const PAIRS: &[(char, char)] = &[ ('`', '`'), ]; -// [TODO] build this dynamically in language config. see #992 -const OPEN_BEFORE: &str = "([{'\":;,> \n\r\u{000B}\u{000C}\u{0085}\u{2028}\u{2029}"; -const CLOSE_BEFORE: &str = ")]}'\":;,> \n\r\u{000B}\u{000C}\u{0085}\u{2028}\u{2029}"; // includes space and newlines +/// The type that represents the collection of auto pairs, +/// keyed by the opener. +#[derive(Debug, Clone)] +pub struct AutoPairs(HashMap); + +/// Represents the config for a particular pairing. +#[derive(Debug, Clone, Copy)] +pub struct Pair { + pub open: char, + pub close: char, +} + +impl Pair { + /// true if open == close + pub fn same(&self) -> bool { + self.open == self.close + } + + /// true if all of the pair's conditions hold for the given document and range + pub fn should_close(&self, doc: &Rope, range: &Range) -> bool { + let mut should_close = Self::next_is_not_alpha(doc, range); + + if self.same() { + should_close &= Self::prev_is_not_alpha(doc, range); + } + + should_close + } + + pub fn next_is_not_alpha(doc: &Rope, range: &Range) -> bool { + let cursor = range.cursor(doc.slice(..)); + let next_char = doc.get_char(cursor); + next_char.map(|c| !c.is_alphanumeric()).unwrap_or(true) + } + + pub fn prev_is_not_alpha(doc: &Rope, range: &Range) -> bool { + let cursor = range.cursor(doc.slice(..)); + let prev_char = prev_char(doc, cursor); + prev_char.map(|c| !c.is_alphanumeric()).unwrap_or(true) + } +} + +impl From<&(char, char)> for Pair { + fn from(&(open, close): &(char, char)) -> Self { + Self { open, close } + } +} + +impl From<(&char, &char)> for Pair { + fn from((open, close): (&char, &char)) -> Self { + Self { + open: *open, + close: *close, + } + } +} + +impl AutoPairs { + /// Make a new AutoPairs set with the given pairs and default conditions. + pub fn new<'a, V: 'a, A>(pairs: V) -> Self + where + V: IntoIterator, + A: Into, + { + let mut auto_pairs = HashMap::new(); + + for pair in pairs.into_iter() { + let auto_pair = pair.into(); + + auto_pairs.insert(auto_pair.open, auto_pair); + + if auto_pair.open != auto_pair.close { + auto_pairs.insert(auto_pair.close, auto_pair); + } + } + + Self(auto_pairs) + } + + pub fn get(&self, ch: char) -> Option<&Pair> { + self.0.get(&ch) + } +} + +impl Default for AutoPairs { + fn default() -> Self { + AutoPairs::new(DEFAULT_PAIRS.iter()) + } +} // insert hook: // Fn(doc, selection, char) => Option @@ -36,21 +124,17 @@ const CLOSE_BEFORE: &str = ")]}'\":;,> \n\r\u{000B}\u{000C}\u{0085}\u{2028}\u{20 // middle of triple quotes, and more exotic pairs like Jinja's {% %} #[must_use] -pub fn hook(doc: &Rope, selection: &Selection, ch: char) -> Option { +pub fn hook(doc: &Rope, selection: &Selection, ch: char, pairs: &AutoPairs) -> Option { debug!("autopairs hook selection: {:#?}", selection); - for &(open, close) in PAIRS { - if open == ch { - if open == close { - return Some(handle_same(doc, selection, open, CLOSE_BEFORE, OPEN_BEFORE)); - } else { - return Some(handle_open(doc, selection, open, close, CLOSE_BEFORE)); - } - } - - if close == ch { + if let Some(pair) = pairs.get(ch) { + if pair.same() { + return Some(handle_same(doc, selection, pair)); + } else if pair.open == ch { + return Some(handle_open(doc, selection, pair)); + } else if pair.close == ch { // && char_at pos == close - return Some(handle_close(doc, selection, open, close)); + return Some(handle_close(doc, selection, pair)); } } @@ -196,13 +280,7 @@ fn get_next_range( Range::new(end_anchor, end_head) } -fn handle_open( - doc: &Rope, - selection: &Selection, - open: char, - close: char, - close_before: &str, -) -> Transaction { +fn handle_open(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction { let mut end_ranges = SmallVec::with_capacity(selection.len()); let mut offs = 0; @@ -212,22 +290,21 @@ fn handle_open( let len_inserted; let change = match next_char { - Some(ch) if !close_before.contains(ch) => { - len_inserted = open.len_utf8(); + Some(_) if !pair.should_close(doc, start_range) => { + len_inserted = pair.open.len_utf8(); let mut tendril = Tendril::new(); - tendril.push(open); + tendril.push(pair.open); (cursor, cursor, Some(tendril)) } - // None | Some(ch) if close_before.contains(ch) => {} _ => { // insert open & close - let pair = Tendril::from_iter([open, close]); - len_inserted = open.len_utf8() + close.len_utf8(); - (cursor, cursor, Some(pair)) + let pair_str = Tendril::from_iter([pair.open, pair.close]); + len_inserted = pair.open.len_utf8() + pair.close.len_utf8(); + (cursor, cursor, Some(pair_str)) } }; - let next_range = get_next_range(doc, start_range, offs, open, len_inserted); + let next_range = get_next_range(doc, start_range, offs, pair.open, len_inserted); end_ranges.push(next_range); offs += len_inserted; @@ -239,7 +316,7 @@ fn handle_open( t } -fn handle_close(doc: &Rope, selection: &Selection, _open: char, close: char) -> Transaction { +fn handle_close(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction { let mut end_ranges = SmallVec::with_capacity(selection.len()); let mut offs = 0; @@ -249,17 +326,17 @@ fn handle_close(doc: &Rope, selection: &Selection, _open: char, close: char) -> let next_char = doc.get_char(cursor); let mut len_inserted = 0; - let change = if next_char == Some(close) { + let change = if next_char == Some(pair.close) { // return transaction that moves past close (cursor, cursor, None) // no-op } else { - len_inserted += close.len_utf8(); + len_inserted += pair.close.len_utf8(); let mut tendril = Tendril::new(); - tendril.push(close); + tendril.push(pair.close); (cursor, cursor, Some(tendril)) }; - let next_range = get_next_range(doc, start_range, offs, close, len_inserted); + let next_range = get_next_range(doc, start_range, offs, pair.close, len_inserted); end_ranges.push(next_range); offs += len_inserted; @@ -272,13 +349,7 @@ fn handle_close(doc: &Rope, selection: &Selection, _open: char, close: char) -> } /// handle cases where open and close is the same, or in triples ("""docstring""") -fn handle_same( - doc: &Rope, - selection: &Selection, - token: char, - close_before: &str, - open_before: &str, -) -> Transaction { +fn handle_same(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction { let mut end_ranges = SmallVec::with_capacity(selection.len()); let mut offs = 0; @@ -286,30 +357,26 @@ fn handle_same( let transaction = Transaction::change_by_selection(doc, selection, |start_range| { let cursor = start_range.cursor(doc.slice(..)); let mut len_inserted = 0; - let next_char = doc.get_char(cursor); - let prev_char = prev_char(doc, cursor); - let change = if next_char == Some(token) { + let change = if next_char == Some(pair.open) { // return transaction that moves past close (cursor, cursor, None) // no-op } else { - let mut pair = Tendril::new(); - pair.push(token); + let mut pair_str = Tendril::new(); + pair_str.push(pair.open); // for equal pairs, don't insert both open and close if either // side has a non-pair char - if (next_char.is_none() || close_before.contains(next_char.unwrap())) - && (prev_char.is_none() || open_before.contains(prev_char.unwrap())) - { - pair.push(token); + if pair.should_close(doc, start_range) { + pair_str.push(pair.close); } - len_inserted += pair.len(); - (cursor, cursor, Some(pair)) + len_inserted += pair_str.len(); + (cursor, cursor, Some(pair_str)) }; - let next_range = get_next_range(doc, start_range, offs, token, len_inserted); + let next_range = get_next_range(doc, start_range, offs, pair.open, len_inserted); end_ranges.push(next_range); offs += len_inserted; @@ -329,21 +396,23 @@ mod test { const LINE_END: &str = crate::DEFAULT_LINE_ENDING.as_str(); fn differing_pairs() -> impl Iterator { - PAIRS.iter().filter(|(open, close)| open != close) + DEFAULT_PAIRS.iter().filter(|(open, close)| open != close) } fn matching_pairs() -> impl Iterator { - PAIRS.iter().filter(|(open, close)| open == close) + DEFAULT_PAIRS.iter().filter(|(open, close)| open == close) } fn test_hooks( in_doc: &Rope, in_sel: &Selection, ch: char, + pairs: &[(char, char)], expected_doc: &Rope, expected_sel: &Selection, ) { - let trans = hook(in_doc, in_sel, ch).unwrap(); + let pairs = AutoPairs::new(pairs.iter()); + let trans = hook(in_doc, in_sel, ch, &pairs).unwrap(); let mut actual_doc = in_doc.clone(); assert!(trans.apply(&mut actual_doc)); assert_eq!(expected_doc, &actual_doc); @@ -353,7 +422,8 @@ mod test { fn test_hooks_with_pairs( in_doc: &Rope, in_sel: &Selection, - pairs: I, + test_pairs: I, + pairs: &[(char, char)], get_expected_doc: F, actual_sel: &Selection, ) where @@ -362,11 +432,12 @@ mod test { R: Into, Rope: From, { - pairs.into_iter().for_each(|(open, close)| { + test_pairs.into_iter().for_each(|(open, close)| { test_hooks( in_doc, in_sel, *open, + pairs, &Rope::from(get_expected_doc(*open, *close)), actual_sel, ) @@ -381,7 +452,8 @@ mod test { test_hooks_with_pairs( &Rope::from(LINE_END), &Selection::single(1, 0), - PAIRS, + DEFAULT_PAIRS, + DEFAULT_PAIRS, |open, close| format!("{}{}{}", open, close, LINE_END), &Selection::single(2, 1), ); @@ -391,7 +463,8 @@ mod test { test_hooks_with_pairs( &empty_doc, &Selection::single(empty_doc.len_chars(), LINE_END.len()), - PAIRS, + DEFAULT_PAIRS, + DEFAULT_PAIRS, |open, close| { format!( "{line_end}{open}{close}{line_end}", @@ -406,13 +479,16 @@ mod test { #[test] fn test_insert_before_multi_code_point_graphemes() { - test_hooks_with_pairs( - &Rope::from(format!("hello 👨‍👩‍👧‍👦 goodbye{}", LINE_END)), - &Selection::single(13, 6), - PAIRS, - |open, _| format!("hello {}👨‍👩‍👧‍👦 goodbye{}", open, LINE_END), - &Selection::single(14, 7), - ); + for (_, close) in differing_pairs() { + test_hooks( + &Rope::from(format!("hello 👨‍👩‍👧‍👦 goodbye{}", LINE_END)), + &Selection::single(13, 6), + *close, + DEFAULT_PAIRS, + &Rope::from(format!("hello {}👨‍👩‍👧‍👦 goodbye{}", close, LINE_END)), + &Selection::single(14, 7), + ); + } } #[test] @@ -420,7 +496,8 @@ mod test { test_hooks_with_pairs( &Rope::from(LINE_END), &Selection::single(LINE_END.len(), LINE_END.len()), - PAIRS, + DEFAULT_PAIRS, + DEFAULT_PAIRS, |open, close| format!("{}{}{}", LINE_END, open, close), &Selection::single(LINE_END.len() + 1, LINE_END.len() + 1), ); @@ -428,7 +505,8 @@ mod test { test_hooks_with_pairs( &Rope::from(format!("foo{}", LINE_END)), &Selection::single(3 + LINE_END.len(), 3 + LINE_END.len()), - PAIRS, + DEFAULT_PAIRS, + DEFAULT_PAIRS, |open, close| format!("foo{}{}{}", LINE_END, open, close), &Selection::single(LINE_END.len() + 4, LINE_END.len() + 4), ); @@ -442,7 +520,8 @@ mod test { &Rope::from(format!("{line_end}{line_end}", line_end = LINE_END)), // before inserting the pair, the cursor covers all of both empty lines &Selection::single(0, LINE_END.len() * 2), - PAIRS, + DEFAULT_PAIRS, + DEFAULT_PAIRS, |open, close| { format!( "{line_end}{open}{close}{line_end}", @@ -467,7 +546,8 @@ mod test { smallvec!(Range::new(1, 0), Range::new(2, 1), Range::new(3, 2),), 0, ), - PAIRS, + DEFAULT_PAIRS, + DEFAULT_PAIRS, |open, close| { format!( "{open}{close}\n{open}{close}\n{open}{close}\n", @@ -489,6 +569,7 @@ mod test { &Rope::from("foo\n"), &Selection::single(2, 4), differing_pairs(), + DEFAULT_PAIRS, |open, close| format!("foo{}{}\n", open, close), &Selection::single(2, 5), ); @@ -501,6 +582,7 @@ mod test { &Rope::from(format!("foo{}", LINE_END)), &Selection::single(3, 3 + LINE_END.len()), differing_pairs(), + DEFAULT_PAIRS, |open, close| format!("foo{}{}{}", open, close, LINE_END), &Selection::single(4, 5), ); @@ -518,6 +600,7 @@ mod test { 0, ), differing_pairs(), + DEFAULT_PAIRS, |open, close| { format!( "foo{open}{close}\nfoo{open}{close}\nfoo{open}{close}\n", @@ -535,13 +618,14 @@ mod test { /// ([)] -> insert ) -> ()[] #[test] fn test_insert_close_inside_pair() { - for (open, close) in PAIRS { + for (open, close) in DEFAULT_PAIRS { let doc = Rope::from(format!("{}{}{}", open, close, LINE_END)); test_hooks( &doc, &Selection::single(2, 1), *close, + DEFAULT_PAIRS, &doc, &Selection::single(2 + LINE_END.len(), 2), ); @@ -551,13 +635,14 @@ mod test { /// [(]) -> append ) -> [()] #[test] fn test_append_close_inside_pair() { - for (open, close) in PAIRS { + for (open, close) in DEFAULT_PAIRS { let doc = Rope::from(format!("{}{}{}", open, close, LINE_END)); test_hooks( &doc, &Selection::single(0, 2), *close, + DEFAULT_PAIRS, &doc, &Selection::single(0, 2 + LINE_END.len()), ); @@ -579,14 +664,14 @@ mod test { 0, ); - for (open, close) in PAIRS { + for (open, close) in DEFAULT_PAIRS { let doc = Rope::from(format!( "{open}{close}\n{open}{close}\n{open}{close}\n", open = open, close = close )); - test_hooks(&doc, &sel, *close, &doc, &expected_sel); + test_hooks(&doc, &sel, *close, DEFAULT_PAIRS, &doc, &expected_sel); } } @@ -605,14 +690,14 @@ mod test { 0, ); - for (open, close) in PAIRS { + for (open, close) in DEFAULT_PAIRS { let doc = Rope::from(format!( "{open}{close}\n{open}{close}\n{open}{close}\n", open = open, close = close )); - test_hooks(&doc, &sel, *close, &doc, &expected_sel); + test_hooks(&doc, &sel, *close, DEFAULT_PAIRS, &doc, &expected_sel); } } @@ -630,7 +715,14 @@ mod test { close = close )); - test_hooks(&doc, &sel, *open, &expected_doc, &expected_sel); + test_hooks( + &doc, + &sel, + *open, + DEFAULT_PAIRS, + &expected_doc, + &expected_sel, + ); } } @@ -648,7 +740,14 @@ mod test { close = close )); - test_hooks(&doc, &sel, *open, &expected_doc, &expected_sel); + test_hooks( + &doc, + &sel, + *open, + DEFAULT_PAIRS, + &expected_doc, + &expected_sel, + ); } } @@ -667,7 +766,14 @@ mod test { outer_open, inner_open, inner_close, outer_close )); - test_hooks(&doc, &sel, *inner_open, &expected_doc, &expected_sel); + test_hooks( + &doc, + &sel, + *inner_open, + DEFAULT_PAIRS, + &expected_doc, + &expected_sel, + ); } } } @@ -687,7 +793,14 @@ mod test { outer_open, inner_open, inner_close, outer_close )); - test_hooks(&doc, &sel, *inner_open, &expected_doc, &expected_sel); + test_hooks( + &doc, + &sel, + *inner_open, + DEFAULT_PAIRS, + &expected_doc, + &expected_sel, + ); } } } @@ -698,7 +811,8 @@ mod test { test_hooks_with_pairs( &Rope::from("word"), &Selection::single(1, 0), - PAIRS, + DEFAULT_PAIRS, + DEFAULT_PAIRS, |open, _| format!("{}word", open), &Selection::single(2, 1), ) @@ -710,7 +824,8 @@ mod test { test_hooks_with_pairs( &Rope::from("word"), &Selection::single(3, 0), - PAIRS, + DEFAULT_PAIRS, + DEFAULT_PAIRS, |open, _| format!("{}word", open), &Selection::single(4, 1), ) @@ -722,10 +837,17 @@ mod test { let sel = Selection::single(0, 4); let expected_sel = Selection::single(0, 5); - for (_, close) in PAIRS { + for (_, close) in DEFAULT_PAIRS { let doc = Rope::from("word"); let expected_doc = Rope::from(format!("wor{}d", close)); - test_hooks(&doc, &sel, *close, &expected_doc, &expected_sel); + test_hooks( + &doc, + &sel, + *close, + DEFAULT_PAIRS, + &expected_doc, + &expected_sel, + ); } } @@ -736,6 +858,7 @@ mod test { &Rope::from("foo word"), &Selection::single(7, 3), differing_pairs(), + DEFAULT_PAIRS, |open, close| format!("foo{}{} word", open, close), &Selection::single(9, 4), ) @@ -749,6 +872,7 @@ mod test { &Rope::from(format!("foo{}{} word{}", open, close, LINE_END)), &Selection::single(9, 4), *close, + DEFAULT_PAIRS, &Rope::from(format!("foo{}{} word{}", open, close, LINE_END)), &Selection::single(9, 5), ) @@ -771,6 +895,7 @@ mod test { &doc, &sel, differing_pairs(), + DEFAULT_PAIRS, |open, close| format!("word{}{}{}", open, close, LINE_END), &expected_sel, ); @@ -779,8 +904,34 @@ mod test { &doc, &sel, matching_pairs(), + DEFAULT_PAIRS, |open, _| format!("word{}{}", open, LINE_END), &expected_sel, ); } + + #[test] + fn test_configured_pairs() { + let test_pairs = &[('`', ':'), ('+', '-')]; + + test_hooks_with_pairs( + &Rope::from(LINE_END), + &Selection::single(1, 0), + test_pairs, + test_pairs, + |open, close| format!("{}{}{}", open, close, LINE_END), + &Selection::single(2, 1), + ); + + let doc = Rope::from(format!("foo`: word{}", LINE_END)); + + test_hooks( + &doc, + &Selection::single(9, 4), + ':', + test_pairs, + &doc, + &Selection::single(9, 5), + ) + } } diff --git a/helix-core/src/chars.rs b/helix-core/src/chars.rs index 549915740..817bbb86b 100644 --- a/helix-core/src/chars.rs +++ b/helix-core/src/chars.rs @@ -91,7 +91,10 @@ mod test { #[test] fn test_categorize() { - const EOL_TEST_CASE: &str = "\n\r\u{000B}\u{000C}\u{0085}\u{2028}\u{2029}"; + #[cfg(not(feature = "unicode-lines"))] + const EOL_TEST_CASE: &str = "\n"; + #[cfg(feature = "unicode-lines")] + const EOL_TEST_CASE: &str = "\n\u{000B}\u{000C}\u{0085}\u{2028}\u{2029}"; const WORD_TEST_CASE: &str = "_hello_world_あいうえおー12345678901234567890"; const PUNCTUATION_TEST_CASE: &str = "!\"#$%&\'()*+,-./:;<=>?@[\\]^`{|}~!”#$%&’()*+、。:;<=>?@「」^`{|}~"; diff --git a/helix-core/src/config.rs b/helix-core/src/config.rs index 5bd16abde..f399850e6 100644 --- a/helix-core/src/config.rs +++ b/helix-core/src/config.rs @@ -1,33 +1,10 @@ -use crate::merge_toml_values; - -/// Default bultin-in languages.toml. -pub fn default_lang_config() -> toml::Value { - toml::from_slice(include_bytes!("../../languages.toml")) - .expect("Could not parse bultin-in languages.toml to valid toml") -} - -/// User configured languages.toml file, merged with the default config. -pub fn user_lang_config() -> Result { - let def_lang_conf = default_lang_config(); - let data = std::fs::read(crate::config_dir().join("languages.toml")); - let user_lang_conf = match data { - Ok(raw) => { - let value = toml::from_slice(&raw)?; - merge_toml_values(def_lang_conf, value) - } - Err(_) => def_lang_conf, - }; - - Ok(user_lang_conf) -} - /// Syntax configuration loader based on built-in languages.toml. pub fn default_syntax_loader() -> crate::syntax::Configuration { - default_lang_config() + helix_loader::default_lang_config() .try_into() - .expect("Could not serialize built-in language.toml") + .expect("Could not serialize built-in languages.toml") } /// Syntax configuration loader based on user configured languages.toml. pub fn user_syntax_loader() -> Result { - user_lang_config()?.try_into() + helix_loader::user_lang_config()?.try_into() } diff --git a/helix-core/src/graphemes.rs b/helix-core/src/graphemes.rs index aa8986844..c0c617750 100644 --- a/helix-core/src/graphemes.rs +++ b/helix-core/src/graphemes.rs @@ -333,10 +333,7 @@ impl<'a> Iterator for RopeGraphemes<'a> { } if a < self.cur_chunk_start { - let a_char = self.text.byte_to_char(a); - let b_char = self.text.byte_to_char(b); - - Some(self.text.slice(a_char..b_char)) + Some(self.text.byte_slice(a..b)) } else { let a2 = a - self.cur_chunk_start; let b2 = b - self.cur_chunk_start; diff --git a/helix-core/src/indent.rs b/helix-core/src/indent.rs index 5d20edc1a..529139b81 100644 --- a/helix-core/src/indent.rs +++ b/helix-core/src/indent.rs @@ -1,6 +1,10 @@ +use std::collections::HashMap; + +use tree_sitter::{Query, QueryCursor, QueryPredicateArg}; + use crate::{ chars::{char_is_line_ending, char_is_whitespace}, - syntax::{IndentQuery, LanguageConfiguration, Syntax}, + syntax::{LanguageConfiguration, RopeProvider, Syntax}, tree_sitter::Node, Rope, RopeSlice, }; @@ -186,103 +190,405 @@ pub fn indent_level_for_line(line: RopeSlice, tab_width: usize) -> usize { len / tab_width } -/// Find the highest syntax node at position. -/// This is to identify the column where this node (e.g., an HTML closing tag) ends. -fn get_highest_syntax_node_at_bytepos(syntax: &Syntax, pos: usize) -> Option { - let tree = syntax.tree(); - - // named_descendant - let mut node = tree.root_node().descendant_for_byte_range(pos, pos)?; - - while let Some(parent) = node.parent() { - if parent.start_byte() == node.start_byte() { - node = parent +/// Computes for node and all ancestors whether they are the first node on their line. +/// The first entry in the return value represents the root node, the last one the node itself +fn get_first_in_line(mut node: Node, byte_pos: usize, new_line: bool) -> Vec { + let mut first_in_line = Vec::new(); + loop { + if let Some(prev) = node.prev_sibling() { + // If we insert a new line, the first node at/after the cursor is considered to be the first in its line + let first = prev.end_position().row != node.start_position().row + || (new_line && node.start_byte() >= byte_pos && prev.start_byte() < byte_pos); + first_in_line.push(Some(first)); + } else { + // Nodes that have no previous siblings are first in their line if and only if their parent is + // (which we don't know yet) + first_in_line.push(None); + } + if let Some(parent) = node.parent() { + node = parent; } else { break; } } - Some(node) + let mut result = Vec::with_capacity(first_in_line.len()); + let mut parent_is_first = true; // The root node is by definition the first node in its line + for first in first_in_line.into_iter().rev() { + if let Some(first) = first { + result.push(first); + parent_is_first = first; + } else { + result.push(parent_is_first); + } + } + result } -/// Calculate the indentation at a given treesitter node. -/// If newline is false, then any "indent" nodes on the line are ignored ("outdent" still applies). -/// This is because the indentation is only increased starting at the second line of the node. -fn calculate_indentation( - query: &IndentQuery, - node: Option, - line: usize, - newline: bool, -) -> usize { - let mut increment: isize = 0; - - let mut node = match node { - Some(node) => node, - None => return 0, - }; +/// The total indent for some line of code. +/// This is usually constructed in one of 2 ways: +/// - Successively add indent captures to get the (added) indent from a single line +/// - Successively add the indent results for each line +#[derive(Default)] +struct Indentation { + /// The total indent (the number of indent levels) is defined as max(0, indent-outdent). + /// The string that this results in depends on the indent style (spaces or tabs, etc.) + indent: usize, + outdent: usize, +} +impl Indentation { + /// Add some other [IndentResult] to this. + /// The added indent should be the total added indent from one line + fn add_line(&mut self, added: &Indentation) { + if added.indent > 0 && added.outdent == 0 { + self.indent += 1; + } else if added.outdent > 0 && added.indent == 0 { + self.outdent += 1; + } + } + /// Add an indent capture to this indent. + /// All the captures that are added in this way should be on the same line. + fn add_capture(&mut self, added: IndentCaptureType) { + match added { + IndentCaptureType::Indent => { + self.indent = 1; + } + IndentCaptureType::Outdent => { + self.outdent = 1; + } + } + } + fn as_string(&self, indent_style: &IndentStyle) -> String { + let indent_level = if self.indent >= self.outdent { + self.indent - self.outdent + } else { + log::warn!("Encountered more outdent than indent nodes while calculating indentation: {} outdent, {} indent", self.outdent, self.indent); + 0 + }; + indent_style.as_str().repeat(indent_level) + } +} - let mut current_line = line; - let mut consider_indent = newline; - let mut increment_from_line: isize = 0; +/// An indent definition which corresponds to a capture from the indent query +struct IndentCapture { + capture_type: IndentCaptureType, + scope: IndentScope, +} +#[derive(Clone, Copy)] +enum IndentCaptureType { + Indent, + Outdent, +} +impl IndentCaptureType { + fn default_scope(&self) -> IndentScope { + match self { + IndentCaptureType::Indent => IndentScope::Tail, + IndentCaptureType::Outdent => IndentScope::All, + } + } +} +/// This defines which part of a node an [IndentCapture] applies to. +/// Each [IndentCaptureType] has a default scope, but the scope can be changed +/// with `#set!` property declarations. +#[derive(Clone, Copy)] +enum IndentScope { + /// The indent applies to the whole node + All, + /// The indent applies to everything except for the first line of the node + Tail, +} - loop { - let node_kind = node.kind(); - let start = node.start_position().row; - if current_line != start { - // Indent/dedent by at most one per line: - // .map(|a| { <-- ({ is two scopes - // let len = 1; <-- indents one level - // }) <-- }) is two scopes - if consider_indent || increment_from_line < 0 { - increment += increment_from_line.signum(); +/// Execute the indent query. +/// Returns for each node (identified by its id) a list of indent captures for that node. +fn query_indents( + query: &Query, + syntax: &Syntax, + cursor: &mut QueryCursor, + text: RopeSlice, + range: std::ops::Range, + // Position of the (optional) newly inserted line break. + // Given as (line, byte_pos) + new_line_break: Option<(usize, usize)>, +) -> HashMap> { + let mut indent_captures: HashMap> = HashMap::new(); + cursor.set_byte_range(range); + // Iterate over all captures from the query + for m in cursor.matches(query, syntax.tree().root_node(), RopeProvider(text)) { + // Skip matches where not all custom predicates are fulfilled + if !query.general_predicates(m.pattern_index).iter().all(|pred| { + match pred.operator.as_ref() { + "not-kind-eq?" => match (pred.args.get(0), pred.args.get(1)) { + ( + Some(QueryPredicateArg::Capture(capture_idx)), + Some(QueryPredicateArg::String(kind)), + ) => { + let node = m.nodes_for_capture_index(*capture_idx).next(); + match node { + Some(node) => node.kind()!=kind.as_ref(), + _ => true, + } + } + _ => { + panic!("Invalid indent query: Arguments to \"not-kind-eq?\" must be a capture and a string"); + } + }, + "same-line?" | "not-same-line?" => { + match (pred.args.get(0), pred.args.get(1)) { + ( + Some(QueryPredicateArg::Capture(capt1)), + Some(QueryPredicateArg::Capture(capt2)) + ) => { + let get_line_num = |node: Node| { + let mut node_line = node.start_position().row; + // Adjust for the new line that will be inserted + if let Some((line, byte)) = new_line_break { + if node_line==line && node.start_byte()>=byte { + node_line += 1; + } + } + node_line + }; + let n1 = m.nodes_for_capture_index(*capt1).next(); + let n2 = m.nodes_for_capture_index(*capt2).next(); + match (n1, n2) { + (Some(n1), Some(n2)) => { + let same_line = get_line_num(n1)==get_line_num(n2); + same_line==(pred.operator.as_ref()=="same-line?") + } + _ => true, + } + } + _ => { + panic!("Invalid indent query: Arguments to \"{}\" must be 2 captures", pred.operator); + } + } + } + _ => { + panic!( + "Invalid indent query: Unknown predicate (\"{}\")", + pred.operator + ); + } } - increment_from_line = 0; - current_line = start; - consider_indent = true; + }) { + continue; } - - if query.outdent.contains(node_kind) { - increment_from_line -= 1; + for capture in m.captures { + let capture_type = query.capture_names()[capture.index as usize].as_str(); + let capture_type = match capture_type { + "indent" => IndentCaptureType::Indent, + "outdent" => IndentCaptureType::Outdent, + _ => { + // Ignore any unknown captures (these may be needed for predicates such as #match?) + continue; + } + }; + let scope = capture_type.default_scope(); + let mut indent_capture = IndentCapture { + capture_type, + scope, + }; + // Apply additional settings for this capture + for property in query.property_settings(m.pattern_index) { + match property.key.as_ref() { + "scope" => { + indent_capture.scope = match property.value.as_deref() { + Some("all") => IndentScope::All, + Some("tail") => IndentScope::Tail, + Some(s) => { + panic!("Invalid indent query: Unknown value for \"scope\" property (\"{}\")", s); + } + None => { + panic!( + "Invalid indent query: Missing value for \"scope\" property" + ); + } + } + } + _ => { + panic!( + "Invalid indent query: Unknown property \"{}\"", + property.key + ); + } + } + } + indent_captures + .entry(capture.node.id()) + // Most entries only need to contain a single IndentCapture + .or_insert_with(|| Vec::with_capacity(1)) + .push(indent_capture); } - if query.indent.contains(node_kind) { - increment_from_line += 1; + } + indent_captures +} + +/// Use the syntax tree to determine the indentation for a given position. +/// This can be used in 2 ways: +/// +/// - To get the correct indentation for an existing line (new_line=false), not necessarily equal to the current indentation. +/// - In this case, pos should be inside the first tree-sitter node on that line. +/// In most cases, this can just be the first non-whitespace on that line. +/// - To get the indentation for a new line (new_line=true). This behaves like the first usecase if the part of the current line +/// after pos were moved to a new line. +/// +/// The indentation is determined by traversing all the tree-sitter nodes containing the position. +/// Each of these nodes produces some [AddedIndent] for: +/// +/// - The line of the (beginning of the) node. This is defined by the scope `all` if this is the first node on its line. +/// - The line after the node. This is defined by: +/// - The scope `tail`. +/// - The scope `all` if this node is not the first node on its line. +/// Intuitively, `all` applies to everything contained in this node while `tail` applies to everything except for the first line of the node. +/// The indents from different nodes for the same line are then combined. +/// The [IndentResult] is simply the sum of the [AddedIndent] for all lines. +/// +/// Specifying which line exactly an [AddedIndent] applies to is important because indents on the same line combine differently than indents on different lines: +/// ```ignore +/// some_function(|| { +/// // Both the function parameters as well as the contained block should be indented. +/// // Because they are on the same line, this only yields one indent level +/// }); +/// ``` +/// +/// ```ignore +/// some_function( +/// parm1, +/// || { +/// // Here we get 2 indent levels because the 'parameters' and the 'block' node begin on different lines +/// }, +/// ); +/// ``` +pub fn treesitter_indent_for_pos( + query: &Query, + syntax: &Syntax, + indent_style: &IndentStyle, + text: RopeSlice, + line: usize, + pos: usize, + new_line: bool, +) -> Option { + let byte_pos = text.char_to_byte(pos); + let mut node = syntax + .tree() + .root_node() + .descendant_for_byte_range(byte_pos, byte_pos)?; + let mut first_in_line = get_first_in_line(node, byte_pos, new_line); + let new_line_break = if new_line { + Some((line, byte_pos)) + } else { + None + }; + let query_result = crate::syntax::PARSER.with(|ts_parser| { + let mut ts_parser = ts_parser.borrow_mut(); + let mut cursor = ts_parser.cursors.pop().unwrap_or_else(QueryCursor::new); + let query_result = query_indents( + query, + syntax, + &mut cursor, + text, + byte_pos..byte_pos + 1, + new_line_break, + ); + ts_parser.cursors.push(cursor); + query_result + }); + + let mut result = Indentation::default(); + // We always keep track of all the indent changes on one line, in order to only indent once + // even if there are multiple "indent" nodes on the same line + let mut indent_for_line = Indentation::default(); + let mut indent_for_line_below = Indentation::default(); + loop { + // This can safely be unwrapped because `first_in_line` contains + // one entry for each ancestor of the node (which is what we iterate over) + let is_first = *first_in_line.last().unwrap(); + // Apply all indent definitions for this node + if let Some(definitions) = query_result.get(&node.id()) { + for definition in definitions { + match definition.scope { + IndentScope::All => { + if is_first { + indent_for_line.add_capture(definition.capture_type); + } else { + indent_for_line_below.add_capture(definition.capture_type); + } + } + IndentScope::Tail => { + indent_for_line_below.add_capture(definition.capture_type); + } + } + } } if let Some(parent) = node.parent() { + let mut node_line = node.start_position().row; + let mut parent_line = parent.start_position().row; + if node_line == line && new_line { + // Also consider the line that will be inserted + if node.start_byte() >= byte_pos { + node_line += 1; + } + if parent.start_byte() >= byte_pos { + parent_line += 1; + } + }; + if node_line != parent_line { + if node_line < line + (new_line as usize) { + // Don't add indent for the line below the line of the query + result.add_line(&indent_for_line_below); + } + if node_line == parent_line + 1 { + indent_for_line_below = indent_for_line; + } else { + result.add_line(&indent_for_line); + indent_for_line_below = Indentation::default(); + } + indent_for_line = Indentation::default(); + } + node = parent; + first_in_line.pop(); } else { + result.add_line(&indent_for_line_below); + result.add_line(&indent_for_line); break; } } - if consider_indent || increment_from_line < 0 { - increment += increment_from_line.signum(); - } - increment.max(0) as usize + Some(result.as_string(indent_style)) } -// TODO: two usecases: if we are triggering this for a new, blank line: -// - it should return 0 when mass indenting stuff -// - it should look up the wrapper node and count it too when we press o/O -pub fn suggested_indent_for_pos( +/// Returns the indentation for a new line. +/// This is done either using treesitter, or if that's not available by copying the indentation from the current line +#[allow(clippy::too_many_arguments)] +pub fn indent_for_newline( language_config: Option<&LanguageConfiguration>, syntax: Option<&Syntax>, + indent_style: &IndentStyle, + tab_width: usize, text: RopeSlice, - pos: usize, - line: usize, - new_line: bool, -) -> Option { + line_before: usize, + line_before_end_pos: usize, + current_line: usize, +) -> String { if let (Some(query), Some(syntax)) = ( language_config.and_then(|config| config.indent_query()), syntax, ) { - let byte_start = text.char_to_byte(pos); - let node = get_highest_syntax_node_at_bytepos(syntax, byte_start); - // TODO: special case for comments - // TODO: if preserve_leading_whitespace - Some(calculate_indentation(query, node, line, new_line)) - } else { - None + if let Some(indent) = treesitter_indent_for_pos( + query, + syntax, + indent_style, + text, + line_before, + line_before_end_pos, + true, + ) { + return indent; + }; } + let indent_level = indent_level_for_line(text.line(current_line), tab_width); + indent_style.as_str().repeat(indent_level) } pub fn get_scopes(syntax: Option<&Syntax>, text: RopeSlice, pos: usize) -> Vec<&'static str> { @@ -326,155 +632,4 @@ mod test { let line = Rope::from("\t \tfn new"); // 1 tab, 4 spaces, tab assert_eq!(indent_level_for_line(line.slice(..), tab_width), 3); } - - #[test] - fn test_suggested_indent_for_line() { - let doc = Rope::from( - " -use std::{ - io::{self, stdout, Stdout, Write}, - path::PathBuf, - sync::Arc, - time::Duration, -} -mod test { - fn hello_world() { - 1 + 1; - - let does_indentation_work = 1; - - let test_function = function_with_param(this_param, - that_param - ); - - let test_function = function_with_param( - this_param, - that_param - ); - - let test_function = function_with_proper_indent(param1, - param2, - ); - - let selection = Selection::new( - changes - .clone() - .map(|(start, end, text): (usize, usize, Option)| { - let len = text.map(|text| text.len()).unwrap() - 1; // minus newline - let pos = start + len; - Range::new(pos, pos) - }) - .collect(), - 0, - ); - - return; - } -} - -impl MyTrait for YourType -where - A: TraitB + TraitC, - D: TraitE + TraitF, -{ - -} -#[test] -// -match test { - Some(a) => 1, - None => { - unimplemented!() - } -} -std::panic::set_hook(Box::new(move |info| { - hook(info); -})); - -{ { { - 1 -}}} - -pub fn change(document: &Document, changes: I) -> Self -where - I: IntoIterator + ExactSizeIterator, -{ - [ - 1, - 2, - 3, - ]; - ( - 1, - 2 - ); - true -} -", - ); - - let doc = doc; - use crate::diagnostic::Severity; - use crate::syntax::{ - Configuration, IndentationConfiguration, LanguageConfiguration, Loader, - }; - use once_cell::sync::OnceCell; - let loader = Loader::new(Configuration { - language: vec![LanguageConfiguration { - scope: "source.rust".to_string(), - file_types: vec!["rs".to_string()], - shebangs: vec![], - language_id: "Rust".to_string(), - highlight_config: OnceCell::new(), - config: None, - // - injection_regex: None, - roots: vec![], - comment_token: None, - auto_format: false, - diagnostic_severity: Severity::Warning, - tree_sitter_library: None, - language_server: None, - indent: Some(IndentationConfiguration { - tab_width: 4, - unit: String::from(" "), - }), - indent_query: OnceCell::new(), - textobject_query: OnceCell::new(), - debugger: None, - }], - }); - - // set runtime path so we can find the queries - let mut runtime = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); - runtime.push("../runtime"); - std::env::set_var("HELIX_RUNTIME", runtime.to_str().unwrap()); - - let language_config = loader.language_config_for_scope("source.rust").unwrap(); - let highlight_config = language_config.highlight_config(&[]).unwrap(); - let syntax = Syntax::new(&doc, highlight_config, std::sync::Arc::new(loader)); - let text = doc.slice(..); - let tab_width = 4; - - for i in 0..doc.len_lines() { - let line = text.line(i); - if let Some(pos) = crate::find_first_non_whitespace_char(line) { - let indent = indent_level_for_line(line, tab_width); - assert_eq!( - suggested_indent_for_pos( - Some(&language_config), - Some(&syntax), - text, - text.line_to_char(i) + pos, - i, - false - ), - Some(indent), - "line {}: \"{}\"", - i, - line - ); - } - } - } } diff --git a/helix-core/src/lib.rs b/helix-core/src/lib.rs index 8e5950deb..1f43c2667 100644 --- a/helix-core/src/lib.rs +++ b/helix-core/src/lib.rs @@ -33,9 +33,6 @@ pub mod unicode { pub use unicode_width as width; } -static RUNTIME_DIR: once_cell::sync::Lazy = - once_cell::sync::Lazy::new(runtime_dir); - pub fn find_first_non_whitespace_char(line: RopeSlice) -> Option { line.chars().position(|ch| !ch.is_whitespace()) } @@ -85,132 +82,6 @@ pub fn find_root(root: Option<&str>, root_markers: &[String]) -> Option std::path::PathBuf { - if let Ok(dir) = std::env::var("HELIX_RUNTIME") { - return dir.into(); - } - - const RT_DIR: &str = "runtime"; - let conf_dir = config_dir().join(RT_DIR); - if conf_dir.exists() { - return conf_dir; - } - - if let Ok(dir) = std::env::var("CARGO_MANIFEST_DIR") { - // this is the directory of the crate being run by cargo, we need the workspace path so we take the parent - return std::path::PathBuf::from(dir).parent().unwrap().join(RT_DIR); - } - - // fallback to location of the executable being run - std::env::current_exe() - .ok() - .and_then(|path| path.parent().map(|path| path.to_path_buf().join(RT_DIR))) - .unwrap() -} - -pub fn config_dir() -> std::path::PathBuf { - // TODO: allow env var override - let strategy = choose_base_strategy().expect("Unable to find the config directory!"); - let mut path = strategy.config_dir(); - path.push("helix"); - path -} - -pub fn cache_dir() -> std::path::PathBuf { - // TODO: allow env var override - let strategy = choose_base_strategy().expect("Unable to find the config directory!"); - let mut path = strategy.cache_dir(); - path.push("helix"); - path -} - -// right overrides left -pub fn merge_toml_values(left: toml::Value, right: toml::Value) -> toml::Value { - use toml::Value; - - fn get_name(v: &Value) -> Option<&str> { - v.get("name").and_then(Value::as_str) - } - - match (left, right) { - (Value::Array(mut left_items), Value::Array(right_items)) => { - left_items.reserve(right_items.len()); - for rvalue in right_items { - let lvalue = get_name(&rvalue) - .and_then(|rname| left_items.iter().position(|v| get_name(v) == Some(rname))) - .map(|lpos| left_items.remove(lpos)); - let mvalue = match lvalue { - Some(lvalue) => merge_toml_values(lvalue, rvalue), - None => rvalue, - }; - left_items.push(mvalue); - } - Value::Array(left_items) - } - (Value::Table(mut left_map), Value::Table(right_map)) => { - for (rname, rvalue) in right_map { - match left_map.remove(&rname) { - Some(lvalue) => { - let merged_value = merge_toml_values(lvalue, rvalue); - left_map.insert(rname, merged_value); - } - None => { - left_map.insert(rname, rvalue); - } - } - } - Value::Table(left_map) - } - // Catch everything else we didn't handle, and use the right value - (_, value) => value, - } -} - -#[cfg(test)] -mod merge_toml_tests { - use super::merge_toml_values; - - #[test] - fn language_tomls() { - use toml::Value; - - const USER: &str = " - [[language]] - name = \"nix\" - test = \"bbb\" - indent = { tab-width = 4, unit = \" \", test = \"aaa\" } - "; - - let base: Value = toml::from_slice(include_bytes!("../../languages.toml")) - .expect("Couldn't parse built-in languages config"); - let user: Value = toml::from_str(USER).unwrap(); - - let merged = merge_toml_values(base, user); - let languages = merged.get("language").unwrap().as_array().unwrap(); - let nix = languages - .iter() - .find(|v| v.get("name").unwrap().as_str().unwrap() == "nix") - .unwrap(); - let nix_indent = nix.get("indent").unwrap(); - - // We changed tab-width and unit in indent so check them if they are the new values - assert_eq!( - nix_indent.get("tab-width").unwrap().as_integer().unwrap(), - 4 - ); - assert_eq!(nix_indent.get("unit").unwrap().as_str().unwrap(), " "); - // We added a new keys, so check them - assert_eq!(nix.get("test").unwrap().as_str().unwrap(), "bbb"); - assert_eq!(nix_indent.get("test").unwrap().as_str().unwrap(), "aaa"); - // We didn't change comment-token so it should be same - assert_eq!(nix.get("comment-token").unwrap().as_str().unwrap(), "#"); - } -} - -pub use etcetera::home_dir; - -use etcetera::base_strategy::{choose_base_strategy, BaseStrategy}; - pub use ropey::{Rope, RopeBuilder, RopeSlice}; // pub use tendril::StrTendril as Tendril; diff --git a/helix-core/src/line_ending.rs b/helix-core/src/line_ending.rs index 8eb426e1e..06ec2a459 100644 --- a/helix-core/src/line_ending.rs +++ b/helix-core/src/line_ending.rs @@ -10,12 +10,18 @@ pub const DEFAULT_LINE_ENDING: LineEnding = LineEnding::LF; pub enum LineEnding { Crlf, // CarriageReturn followed by LineFeed LF, // U+000A -- LineFeed - VT, // U+000B -- VerticalTab - FF, // U+000C -- FormFeed - CR, // U+000D -- CarriageReturn - Nel, // U+0085 -- NextLine - LS, // U+2028 -- Line Separator - PS, // U+2029 -- ParagraphSeparator + #[cfg(feature = "unicode-lines")] + VT, // U+000B -- VerticalTab + #[cfg(feature = "unicode-lines")] + FF, // U+000C -- FormFeed + #[cfg(feature = "unicode-lines")] + CR, // U+000D -- CarriageReturn + #[cfg(feature = "unicode-lines")] + Nel, // U+0085 -- NextLine + #[cfg(feature = "unicode-lines")] + LS, // U+2028 -- Line Separator + #[cfg(feature = "unicode-lines")] + PS, // U+2029 -- ParagraphSeparator } impl LineEnding { @@ -32,11 +38,17 @@ impl LineEnding { match self { Self::Crlf => "\u{000D}\u{000A}", Self::LF => "\u{000A}", + #[cfg(feature = "unicode-lines")] Self::VT => "\u{000B}", + #[cfg(feature = "unicode-lines")] Self::FF => "\u{000C}", + #[cfg(feature = "unicode-lines")] Self::CR => "\u{000D}", + #[cfg(feature = "unicode-lines")] Self::Nel => "\u{0085}", + #[cfg(feature = "unicode-lines")] Self::LS => "\u{2028}", + #[cfg(feature = "unicode-lines")] Self::PS => "\u{2029}", } } @@ -45,11 +57,17 @@ impl LineEnding { pub const fn from_char(ch: char) -> Option { match ch { '\u{000A}' => Some(LineEnding::LF), + #[cfg(feature = "unicode-lines")] '\u{000B}' => Some(LineEnding::VT), + #[cfg(feature = "unicode-lines")] '\u{000C}' => Some(LineEnding::FF), + #[cfg(feature = "unicode-lines")] '\u{000D}' => Some(LineEnding::CR), + #[cfg(feature = "unicode-lines")] '\u{0085}' => Some(LineEnding::Nel), + #[cfg(feature = "unicode-lines")] '\u{2028}' => Some(LineEnding::LS), + #[cfg(feature = "unicode-lines")] '\u{2029}' => Some(LineEnding::PS), // Not a line ending _ => None, @@ -65,11 +83,17 @@ impl LineEnding { match g { "\u{000D}\u{000A}" => Some(LineEnding::Crlf), "\u{000A}" => Some(LineEnding::LF), + #[cfg(feature = "unicode-lines")] "\u{000B}" => Some(LineEnding::VT), + #[cfg(feature = "unicode-lines")] "\u{000C}" => Some(LineEnding::FF), + #[cfg(feature = "unicode-lines")] "\u{000D}" => Some(LineEnding::CR), + #[cfg(feature = "unicode-lines")] "\u{0085}" => Some(LineEnding::Nel), + #[cfg(feature = "unicode-lines")] "\u{2028}" => Some(LineEnding::LS), + #[cfg(feature = "unicode-lines")] "\u{2029}" => Some(LineEnding::PS), // Not a line ending _ => None, @@ -101,7 +125,9 @@ pub fn auto_detect_line_ending(doc: &Rope) -> Option { // are being matched, as they might be special-use only for line in doc.lines().take(100) { match get_line_ending(&line) { - None | Some(LineEnding::VT) | Some(LineEnding::FF) | Some(LineEnding::PS) => {} + None => {} + #[cfg(feature = "unicode-lines")] + Some(LineEnding::VT) | Some(LineEnding::FF) | Some(LineEnding::PS) => {} ending => return ending, } } @@ -128,6 +154,19 @@ pub fn get_line_ending(line: &RopeSlice) -> Option { LineEnding::from_str(g2).or_else(|| LineEnding::from_str(g1)) } +#[cfg(not(feature = "unicode-lines"))] +/// Returns the passed line's line ending, if any. +pub fn get_line_ending_of_str(line: &str) -> Option { + if line.ends_with("\u{000D}\u{000A}") { + Some(LineEnding::Crlf) + } else if line.ends_with('\u{000A}') { + Some(LineEnding::LF) + } else { + None + } +} + +#[cfg(feature = "unicode-lines")] /// Returns the passed line's line ending, if any. pub fn get_line_ending_of_str(line: &str) -> Option { if line.ends_with("\u{000D}\u{000A}") { @@ -211,6 +250,7 @@ mod line_ending_tests { #[test] fn str_to_line_ending() { + #[cfg(feature = "unicode-lines")] assert_eq!(LineEnding::from_str("\r"), Some(LineEnding::CR)); assert_eq!(LineEnding::from_str("\n"), Some(LineEnding::LF)); assert_eq!(LineEnding::from_str("\r\n"), Some(LineEnding::Crlf)); @@ -220,6 +260,7 @@ mod line_ending_tests { #[test] fn rope_slice_to_line_ending() { let r = Rope::from_str("hello\r\n"); + #[cfg(feature = "unicode-lines")] assert_eq!( LineEnding::from_rope_slice(&r.slice(5..6)), Some(LineEnding::CR) @@ -238,6 +279,7 @@ mod line_ending_tests { #[test] fn get_line_ending_rope_slice() { let r = Rope::from_str("Hello\rworld\nhow\r\nare you?"); + #[cfg(feature = "unicode-lines")] assert_eq!(get_line_ending(&r.slice(..6)), Some(LineEnding::CR)); assert_eq!(get_line_ending(&r.slice(..12)), Some(LineEnding::LF)); assert_eq!(get_line_ending(&r.slice(..17)), Some(LineEnding::Crlf)); @@ -247,6 +289,7 @@ mod line_ending_tests { #[test] fn get_line_ending_str() { let text = "Hello\rworld\nhow\r\nare you?"; + #[cfg(feature = "unicode-lines")] assert_eq!(get_line_ending_of_str(&text[..6]), Some(LineEnding::CR)); assert_eq!(get_line_ending_of_str(&text[..12]), Some(LineEnding::LF)); assert_eq!(get_line_ending_of_str(&text[..17]), Some(LineEnding::Crlf)); @@ -257,9 +300,8 @@ mod line_ending_tests { fn line_end_char_index_rope_slice() { let r = Rope::from_str("Hello\rworld\nhow\r\nare you?"); let s = &r.slice(..); - assert_eq!(line_end_char_index(s, 0), 5); - assert_eq!(line_end_char_index(s, 1), 11); - assert_eq!(line_end_char_index(s, 2), 15); - assert_eq!(line_end_char_index(s, 3), 25); + assert_eq!(line_end_char_index(s, 0), 11); + assert_eq!(line_end_char_index(s, 1), 15); + assert_eq!(line_end_char_index(s, 2), 25); } } diff --git a/helix-core/src/path.rs b/helix-core/src/path.rs index a66444651..e0c3bef65 100644 --- a/helix-core/src/path.rs +++ b/helix-core/src/path.rs @@ -1,9 +1,10 @@ +use etcetera::home_dir; use std::path::{Component, Path, PathBuf}; /// Replaces users home directory from `path` with tilde `~` if the directory /// is available, otherwise returns the path unchanged. pub fn fold_home_dir(path: &Path) -> PathBuf { - if let Ok(home) = super::home_dir() { + if let Ok(home) = home_dir() { if path.starts_with(&home) { // it's ok to unwrap, the path starts with home dir return PathBuf::from("~").join(path.strip_prefix(&home).unwrap()); @@ -20,7 +21,7 @@ pub fn expand_tilde(path: &Path) -> PathBuf { let mut components = path.components().peekable(); if let Some(Component::Normal(c)) = components.peek() { if c == &"~" { - if let Ok(home) = super::home_dir() { + if let Ok(home) = home_dir() { // it's ok to unwrap, the path starts with `~` return home.join(path.strip_prefix("~").unwrap()); } diff --git a/helix-core/src/position.rs b/helix-core/src/position.rs index 93362c775..ce37300a4 100644 --- a/helix-core/src/position.rs +++ b/helix-core/src/position.rs @@ -1,8 +1,9 @@ +use std::borrow::Cow; + use crate::{ chars::char_is_line_ending, - graphemes::{ensure_grapheme_boundary_prev, RopeGraphemes}, + graphemes::{ensure_grapheme_boundary_prev, grapheme_width, RopeGraphemes}, line_ending::line_end_char_index, - unicode::width::UnicodeWidthChar, RopeSlice, }; @@ -77,14 +78,17 @@ pub fn visual_coords_at_pos(text: RopeSlice, pos: usize, tab_width: usize) -> Po let line_start = text.line_to_char(line); let pos = ensure_grapheme_boundary_prev(text, pos); - let col = text - .slice(line_start..pos) - .chars() - .flat_map(|c| match c { - '\t' => Some(tab_width), - c => UnicodeWidthChar::width(c), - }) - .sum(); + + let mut col = 0; + + for grapheme in RopeGraphemes::new(text.slice(line_start..pos)) { + if grapheme == "\t" { + col += tab_width - (col % tab_width); + } else { + let grapheme = Cow::from(grapheme); + col += grapheme_width(&grapheme); + } + } Position::new(line, col) } diff --git a/helix-core/src/surround.rs b/helix-core/src/surround.rs index 58eb23cf2..c14456b73 100644 --- a/helix-core/src/surround.rs +++ b/helix-core/src/surround.rs @@ -1,3 +1,5 @@ +use std::fmt::Display; + use crate::{search, Range, Selection}; use ropey::RopeSlice; @@ -11,6 +13,27 @@ pub const PAIRS: &[(char, char)] = &[ ('(', ')'), ]; +#[derive(Debug, PartialEq)] +pub enum Error { + PairNotFound, + CursorOverlap, + RangeExceedsText, + CursorOnAmbiguousPair, +} + +impl Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match *self { + Error::PairNotFound => "Surround pair not found around all cursors", + Error::CursorOverlap => "Cursors overlap for a single surround pair range", + Error::RangeExceedsText => "Cursor range exceeds text length", + Error::CursorOnAmbiguousPair => "Cursor on ambiguous surround pair", + }) + } +} + +type Result = std::result::Result; + /// Given any char in [PAIRS], return the open and closing chars. If not found in /// [PAIRS] return (ch, ch). /// @@ -37,31 +60,36 @@ pub fn find_nth_pairs_pos( ch: char, range: Range, n: usize, -) -> Option<(usize, usize)> { - if text.len_chars() < 2 || range.to() >= text.len_chars() { - return None; +) -> Result<(usize, usize)> { + if text.len_chars() < 2 { + return Err(Error::PairNotFound); + } + if range.to() >= text.len_chars() { + return Err(Error::RangeExceedsText); } let (open, close) = get_pair(ch); let pos = range.cursor(text); - if open == close { + let (open, close) = if open == close { if Some(open) == text.get_char(pos) { // Cursor is directly on match char. We return no match // because there's no way to know which side of the char // we should be searching on. - return None; + return Err(Error::CursorOnAmbiguousPair); } - Some(( - search::find_nth_prev(text, open, pos, n)?, - search::find_nth_next(text, close, pos, n)?, - )) + ( + search::find_nth_prev(text, open, pos, n), + search::find_nth_next(text, close, pos, n), + ) } else { - Some(( - find_nth_open_pair(text, open, close, pos, n)?, - find_nth_close_pair(text, open, close, pos, n)?, - )) - } + ( + find_nth_open_pair(text, open, close, pos, n), + find_nth_close_pair(text, open, close, pos, n), + ) + }; + + Option::zip(open, close).ok_or(Error::PairNotFound) } fn find_nth_open_pair( @@ -151,17 +179,17 @@ pub fn get_surround_pos( selection: &Selection, ch: char, skip: usize, -) -> Option> { +) -> Result> { let mut change_pos = Vec::new(); for &range in selection { let (open_pos, close_pos) = find_nth_pairs_pos(text, ch, range, skip)?; if change_pos.contains(&open_pos) || change_pos.contains(&close_pos) { - return None; + return Err(Error::CursorOverlap); } change_pos.extend_from_slice(&[open_pos, close_pos]); } - Some(change_pos) + Ok(change_pos) } #[cfg(test)] @@ -175,7 +203,7 @@ mod test { #[allow(clippy::type_complexity)] fn check_find_nth_pair_pos( text: &str, - cases: Vec<(usize, char, usize, Option<(usize, usize)>)>, + cases: Vec<(usize, char, usize, Result<(usize, usize)>)>, ) { let doc = Rope::from(text); let slice = doc.slice(..); @@ -196,13 +224,13 @@ mod test { "some (text) here", vec![ // cursor on [t]ext - (6, '(', 1, Some((5, 10))), - (6, ')', 1, Some((5, 10))), + (6, '(', 1, Ok((5, 10))), + (6, ')', 1, Ok((5, 10))), // cursor on so[m]e - (2, '(', 1, None), + (2, '(', 1, Err(Error::PairNotFound)), // cursor on bracket itself - (5, '(', 1, Some((5, 10))), - (10, '(', 1, Some((5, 10))), + (5, '(', 1, Ok((5, 10))), + (10, '(', 1, Ok((5, 10))), ], ); } @@ -213,9 +241,9 @@ mod test { "(so (many (good) text) here)", vec![ // cursor on go[o]d - (13, '(', 1, Some((10, 15))), - (13, '(', 2, Some((4, 21))), - (13, '(', 3, Some((0, 27))), + (13, '(', 1, Ok((10, 15))), + (13, '(', 2, Ok((4, 21))), + (13, '(', 3, Ok((0, 27))), ], ); } @@ -226,11 +254,11 @@ mod test { "'so 'many 'good' text' here'", vec![ // cursor on go[o]d - (13, '\'', 1, Some((10, 15))), - (13, '\'', 2, Some((4, 21))), - (13, '\'', 3, Some((0, 27))), + (13, '\'', 1, Ok((10, 15))), + (13, '\'', 2, Ok((4, 21))), + (13, '\'', 3, Ok((0, 27))), // cursor on the quotes - (10, '\'', 1, None), + (10, '\'', 1, Err(Error::CursorOnAmbiguousPair)), ], ) } @@ -241,8 +269,8 @@ mod test { "((so)((many) good (text))(here))", vec![ // cursor on go[o]d - (15, '(', 1, Some((5, 24))), - (15, '(', 2, Some((0, 31))), + (15, '(', 1, Ok((5, 24))), + (15, '(', 2, Ok((0, 31))), ], ) } @@ -253,9 +281,9 @@ mod test { "(so [many {good} text] here)", vec![ // cursor on go[o]d - (13, '{', 1, Some((10, 15))), - (13, '[', 1, Some((4, 21))), - (13, '(', 1, Some((0, 27))), + (13, '{', 1, Ok((10, 15))), + (13, '[', 1, Ok((4, 21))), + (13, '(', 1, Ok((0, 27))), ], ) } @@ -285,11 +313,10 @@ mod test { let selection = Selection::new(SmallVec::from_slice(&[Range::point(2), Range::point(9)]), 0); - // cursor on s[o]me, c[h]ars assert_eq!( get_surround_pos(slice, &selection, '(', 1), - None // different surround chars + Err(Error::PairNotFound) // different surround chars ); let selection = Selection::new( @@ -299,7 +326,15 @@ mod test { // cursor on [x]x, newli[n]e assert_eq!( get_surround_pos(slice, &selection, '(', 1), - None // overlapping surround chars + Err(Error::PairNotFound) // overlapping surround chars + ); + + let selection = + Selection::new(SmallVec::from_slice(&[Range::point(2), Range::point(3)]), 0); + // cursor on s[o][m]e + assert_eq!( + get_surround_pos(slice, &selection, '[', 1), + Err(Error::CursorOverlap) ); } } diff --git a/helix-core/src/syntax.rs b/helix-core/src/syntax.rs index ccf91100f..dde7e90cd 100644 --- a/helix-core/src/syntax.rs +++ b/helix-core/src/syntax.rs @@ -1,4 +1,5 @@ use crate::{ + auto_pairs::AutoPairs, chars::char_is_line_ending, diagnostic::Severity, regex::Regex, @@ -6,8 +7,6 @@ use crate::{ Rope, RopeSlice, Tendril, }; -pub use helix_syntax::get_language; - use arc_swap::{ArcSwap, Guard}; use slotmap::{DefaultKey as LayerId, HopSlotMap}; @@ -17,12 +16,15 @@ use std::{ collections::{HashMap, HashSet, VecDeque}, fmt, path::Path, + str::FromStr, sync::Arc, }; use once_cell::sync::{Lazy, OnceCell}; use serde::{Deserialize, Serialize}; +use helix_loader::grammar::{get_language, load_runtime_file}; + fn deserialize_regex<'de, D>(deserializer: D) -> Result, D::Error> where D: serde::Deserializer<'de>, @@ -41,8 +43,14 @@ where .transpose() } +pub fn deserialize_auto_pairs<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + Ok(Option::::deserialize(deserializer)?.and_then(AutoPairConfig::into)) +} + #[derive(Debug, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] pub struct Configuration { pub language: Vec, } @@ -68,7 +76,7 @@ pub struct LanguageConfiguration { #[serde(default)] pub diagnostic_severity: Severity, - pub tree_sitter_library: Option, // tree-sitter library name, defaults to language_id + pub grammar: Option, // tree-sitter grammar name, defaults to language_id // content_regex #[serde(default, skip_serializing, deserialize_with = "deserialize_regex")] @@ -84,11 +92,18 @@ pub struct LanguageConfiguration { pub indent: Option, #[serde(skip)] - pub(crate) indent_query: OnceCell>, + pub(crate) indent_query: OnceCell>, #[serde(skip)] pub(crate) textobject_query: OnceCell>, #[serde(skip_serializing_if = "Option::is_none")] pub debugger: Option, + + /// Automatic insertion of pairs to parentheses, brackets, + /// etc. Defaults to true. Optionally, this can be a list of 2-tuples + /// to specify a list of characters to pair. This overrides the + /// global setting. + #[serde(default, skip_serializing, deserialize_with = "deserialize_auto_pairs")] + pub auto_pairs: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -162,15 +177,47 @@ pub struct IndentationConfiguration { pub unit: String, } -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub struct IndentQuery { - #[serde(default)] - #[serde(skip_serializing_if = "HashSet::is_empty")] - pub indent: HashSet, - #[serde(default)] - #[serde(skip_serializing_if = "HashSet::is_empty")] - pub outdent: HashSet, +/// Configuration for auto pairs +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields, untagged)] +pub enum AutoPairConfig { + /// Enables or disables auto pairing. False means disabled. True means to use the default pairs. + Enable(bool), + + /// The mappings of pairs. + Pairs(HashMap), +} + +impl Default for AutoPairConfig { + fn default() -> Self { + AutoPairConfig::Enable(true) + } +} + +impl From<&AutoPairConfig> for Option { + fn from(auto_pair_config: &AutoPairConfig) -> Self { + match auto_pair_config { + AutoPairConfig::Enable(false) => None, + AutoPairConfig::Enable(true) => Some(AutoPairs::default()), + AutoPairConfig::Pairs(pairs) => Some(AutoPairs::new(pairs.iter())), + } + } +} + +impl From for Option { + fn from(auto_pairs_config: AutoPairConfig) -> Self { + (&auto_pairs_config).into() + } +} + +impl FromStr for AutoPairConfig { + type Err = std::str::ParseBoolError; + + // only do bool parsing for runtime setting + fn from_str(s: &str) -> Result { + let enable: bool = s.parse()?; + Ok(AutoPairConfig::Enable(enable)) + } } #[derive(Debug)] @@ -178,16 +225,56 @@ pub struct TextObjectQuery { pub query: Query, } +pub enum CapturedNode<'a> { + Single(Node<'a>), + /// Guarenteed to be not empty + Grouped(Vec>), +} + +impl<'a> CapturedNode<'a> { + pub fn start_byte(&self) -> usize { + match self { + Self::Single(n) => n.start_byte(), + Self::Grouped(ns) => ns[0].start_byte(), + } + } + + pub fn end_byte(&self) -> usize { + match self { + Self::Single(n) => n.end_byte(), + Self::Grouped(ns) => ns.last().unwrap().end_byte(), + } + } + + pub fn byte_range(&self) -> std::ops::Range { + self.start_byte()..self.end_byte() + } +} + impl TextObjectQuery { /// Run the query on the given node and return sub nodes which match given /// capture ("function.inside", "class.around", etc). + /// + /// Captures may contain multiple nodes by using quantifiers (+, *, etc), + /// and support for this is partial and could use improvement. + /// + /// ```query + /// ;; supported: + /// (comment)+ @capture + /// + /// ;; unsupported: + /// ( + /// (comment)+ + /// (function) + /// ) @capture + /// ``` pub fn capture_nodes<'a>( &'a self, capture_name: &str, node: Node<'a>, slice: RopeSlice<'a>, cursor: &'a mut QueryCursor, - ) -> Option>> { + ) -> Option>> { self.capture_nodes_any(&[capture_name], node, slice, cursor) } @@ -199,28 +286,36 @@ impl TextObjectQuery { node: Node<'a>, slice: RopeSlice<'a>, cursor: &'a mut QueryCursor, - ) -> Option>> { + ) -> Option>> { let capture_idx = capture_names .iter() .find_map(|cap| self.query.capture_index_for_name(cap))?; - let captures = cursor.captures(&self.query, node, RopeProvider(slice)); + let captures = cursor.matches(&self.query, node, RopeProvider(slice)); + + let nodes = captures.flat_map(move |mat| { + let captures = mat.captures.iter().filter(move |c| c.index == capture_idx); + let nodes = captures.map(|c| c.node); + let pattern_idx = mat.pattern_index; + let quantifier = self.query.capture_quantifiers(pattern_idx)[capture_idx as usize]; + + let iter: Box> = match quantifier { + CaptureQuantifier::OneOrMore | CaptureQuantifier::ZeroOrMore => { + let nodes: Vec = nodes.collect(); + if nodes.is_empty() { + Box::new(std::iter::empty()) + } else { + Box::new(std::iter::once(CapturedNode::Grouped(nodes))) + } + } + _ => Box::new(nodes.map(CapturedNode::Single)), + }; - captures - .filter_map(move |(mat, idx)| { - (mat.captures[idx].index == capture_idx).then(|| mat.captures[idx].node) - }) - .into() + iter + }); + Some(nodes) } } -fn load_runtime_file(language: &str, filename: &str) -> Result { - let path = crate::RUNTIME_DIR - .join("queries") - .join(language) - .join(filename); - std::fs::read_to_string(&path) -} - fn read_query(language: &str, filename: &str) -> String { static INHERITS_REGEX: Lazy = Lazy::new(|| Regex::new(r";+\s*inherits\s*:?\s*([a-z_,()]+)\s*").unwrap()); @@ -266,21 +361,16 @@ impl LanguageConfiguration { if highlights_query.is_empty() { None } else { - let language = get_language( - &crate::RUNTIME_DIR, - self.tree_sitter_library - .as_deref() - .unwrap_or(&self.language_id), - ) - .map_err(|e| log::info!("{}", e)) - .ok()?; + let language = get_language(self.grammar.as_deref().unwrap_or(&self.language_id)) + .map_err(|e| log::info!("{}", e)) + .ok()?; let config = HighlightConfiguration::new( language, &highlights_query, &injections_query, &locals_query, ) - .unwrap(); // TODO: avoid panic + .unwrap_or_else(|query_error| panic!("Could not parse queries for language {:?}. Are your grammars out of sync? Try running 'hx --grammar fetch' and 'hx --grammar build'. This query could not be parsed: {:?}", self.language_id, query_error)); config.configure(scopes); Some(Arc::new(config)) @@ -303,13 +393,13 @@ impl LanguageConfiguration { self.highlight_config.get().is_some() } - pub fn indent_query(&self) -> Option<&IndentQuery> { + pub fn indent_query(&self) -> Option<&Query> { self.indent_query .get_or_init(|| { - let language = self.language_id.to_ascii_lowercase(); - - let toml = load_runtime_file(&language, "indents.toml").ok()?; - toml::from_slice(toml.as_bytes()).ok() + let lang_name = self.language_id.to_ascii_lowercase(); + let query_text = read_query(&lang_name, "indents.scm"); + let lang = self.highlight_config.get()?.as_ref()?.language; + Query::new(lang, &query_text).ok() }) .as_ref() } @@ -456,7 +546,7 @@ impl Loader { pub struct TsParser { parser: tree_sitter::Parser, - cursors: Vec, + pub cursors: Vec, } // could also just use a pool, or a single instance? @@ -475,9 +565,7 @@ pub struct Syntax { } fn byte_range_to_str(range: std::ops::Range, source: RopeSlice) -> Cow { - let start_char = source.byte_to_char(range.start); - let end_char = source.byte_to_char(range.end); - Cow::from(source.slice(start_char..end_char)) + Cow::from(source.byte_slice(range)) } impl Syntax { @@ -1009,8 +1097,8 @@ pub(crate) fn generate_edits( use std::sync::atomic::{AtomicUsize, Ordering}; use std::{iter, mem, ops, str, usize}; use tree_sitter::{ - Language as Grammar, Node, Parser, Point, Query, QueryCaptures, QueryCursor, QueryError, - QueryMatch, Range, TextProvider, Tree, + CaptureQuantifier, Language as Grammar, Node, Parser, Point, Query, QueryCaptures, QueryCursor, + QueryError, QueryMatch, Range, TextProvider, Tree, }; const CANCELLATION_CHECK_INTERVAL: usize = 100; @@ -1081,7 +1169,7 @@ struct HighlightIter<'a> { } // Adapter to convert rope chunks to bytes -struct ChunksBytes<'a> { +pub struct ChunksBytes<'a> { chunks: ropey::iter::Chunks<'a>, } impl<'a> Iterator for ChunksBytes<'a> { @@ -1091,14 +1179,12 @@ impl<'a> Iterator for ChunksBytes<'a> { } } -struct RopeProvider<'a>(RopeSlice<'a>); +pub struct RopeProvider<'a>(pub RopeSlice<'a>); impl<'a> TextProvider<'a> for RopeProvider<'a> { type I = ChunksBytes<'a>; fn text(&mut self, node: Node) -> Self::I { - let start_char = self.0.byte_to_char(node.start_byte()); - let end_char = self.0.byte_to_char(node.end_byte()); - let fragment = self.0.slice(start_char..end_char); + let fragment = self.0.byte_slice(node.start_byte()..node.end_byte()); ChunksBytes { chunks: fragment.chunks(), } @@ -1862,6 +1948,50 @@ mod test { use super::*; use crate::{Rope, Transaction}; + #[test] + fn test_textobject_queries() { + let query_str = r#" + (line_comment)+ @quantified_nodes + ((line_comment)+) @quantified_nodes_grouped + ((line_comment) (line_comment)) @multiple_nodes_grouped + "#; + let source = Rope::from_str( + r#" +/// a comment on +/// mutiple lines + "#, + ); + + let loader = Loader::new(Configuration { language: vec![] }); + let language = get_language("Rust").unwrap(); + + let query = Query::new(language, query_str).unwrap(); + let textobject = TextObjectQuery { query }; + let mut cursor = QueryCursor::new(); + + let config = HighlightConfiguration::new(language, "", "", "").unwrap(); + let syntax = Syntax::new(&source, Arc::new(config), Arc::new(loader)); + + let root = syntax.tree().root_node(); + let mut test = |capture, range| { + let matches: Vec<_> = textobject + .capture_nodes(capture, root, source.slice(..), &mut cursor) + .unwrap() + .collect(); + + assert_eq!( + matches[0].byte_range(), + range, + "@{capture} expected {range:?}" + ) + }; + + test("quantified_nodes", 1..35); + // NOTE: Enable after implementing proper node group capturing + // test("quantified_nodes_grouped", 1..35); + // test("multiple_nodes_grouped", 1..35); + } + #[test] fn test_parser() { let highlight_names: Vec = [ @@ -1891,17 +2021,13 @@ mod test { let loader = Loader::new(Configuration { language: vec![] }); - let language = get_language(&crate::RUNTIME_DIR, "Rust").unwrap(); + let language = get_language("Rust").unwrap(); let config = HighlightConfiguration::new( language, - &std::fs::read_to_string( - "../helix-syntax/languages/tree-sitter-rust/queries/highlights.scm", - ) - .unwrap(), - &std::fs::read_to_string( - "../helix-syntax/languages/tree-sitter-rust/queries/injections.scm", - ) - .unwrap(), + &std::fs::read_to_string("../runtime/grammars/sources/rust/queries/highlights.scm") + .unwrap(), + &std::fs::read_to_string("../runtime/grammars/sources/rust/queries/injections.scm") + .unwrap(), "", // locals.scm ) .unwrap(); @@ -1989,7 +2115,7 @@ mod test { #[test] fn test_load_runtime_file() { // Test to make sure we can load some data from the runtime directory. - let contents = load_runtime_file("rust", "indents.toml").unwrap(); + let contents = load_runtime_file("rust", "indents.scm").unwrap(); assert!(!contents.is_empty()); let results = load_runtime_file("rust", "does-not-exist"); diff --git a/helix-core/src/transaction.rs b/helix-core/src/transaction.rs index 2e34a9864..daf4a77e8 100644 --- a/helix-core/src/transaction.rs +++ b/helix-core/src/transaction.rs @@ -21,7 +21,6 @@ pub enum Assoc { After, } -// ChangeSpec = Change | ChangeSet | Vec #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct ChangeSet { pub(crate) changes: Vec, @@ -50,7 +49,6 @@ impl ChangeSet { } // TODO: from iter - // #[doc(hidden)] // used by lsp to convert to LSP changes pub fn changes(&self) -> &[Operation] { @@ -415,8 +413,6 @@ impl ChangeSet { pub struct Transaction { changes: ChangeSet, selection: Option, - // effects, annotations - // scroll_into_view } impl Transaction { @@ -440,14 +436,12 @@ impl Transaction { /// Returns true if applied successfully. pub fn apply(&self, doc: &mut Rope) -> bool { - if !self.changes.is_empty() { - // apply changes to the document - if !self.changes.apply(doc) { - return false; - } + if self.changes.is_empty() { + return true; } - true + // apply changes to the document + self.changes.apply(doc) } /// Generate a transaction that reverts this one. @@ -475,7 +469,7 @@ impl Transaction { /// Generate a transaction from a set of changes. pub fn change(doc: &Rope, changes: I) -> Self where - I: IntoIterator + Iterator, + I: Iterator, { let len = doc.len_chars(); @@ -483,12 +477,11 @@ impl Transaction { let size = upper.unwrap_or(lower); let mut changeset = ChangeSet::with_capacity(2 * size + 1); // rough estimate - // TODO: verify ranges are ordered and not overlapping or change will panic. - - // TODO: test for (pos, pos, None) to factor out as nothing - let mut last = 0; for (from, to, tendril) in changes { + // Verify ranges are ordered and not overlapping + debug_assert!(last <= from); + // Retain from last "to" to current "from" changeset.retain(from - last); let span = to - from; @@ -694,7 +687,7 @@ mod test { let mut doc = Rope::from("hello world!\ntest 123"); let transaction = Transaction::change( &doc, - // (1, 1, None) is a useless 0-width delete + // (1, 1, None) is a useless 0-width delete that gets factored out vec![(1, 1, None), (6, 11, Some("void".into())), (12, 17, None)].into_iter(), ); transaction.apply(&mut doc); diff --git a/helix-core/tests/data/indent/indent.rs b/helix-core/tests/data/indent/indent.rs new file mode 120000 index 000000000..2ac16cf96 --- /dev/null +++ b/helix-core/tests/data/indent/indent.rs @@ -0,0 +1 @@ +../../../src/indent.rs \ No newline at end of file diff --git a/helix-core/tests/data/indent/languages.toml b/helix-core/tests/data/indent/languages.toml new file mode 100644 index 000000000..f9cef4942 --- /dev/null +++ b/helix-core/tests/data/indent/languages.toml @@ -0,0 +1,13 @@ +# This languages.toml should contain definitions for all languages for which we have indent tests +[[language]] +name = "rust" +scope = "source.rust" +injection-regex = "rust" +file-types = ["rs"] +comment-token = "//" +roots = ["Cargo.toml", "Cargo.lock"] +indent = { tab-width = 4, unit = " " } + +[[grammar]] +name = "rust" +source = { git = "https://github.com/tree-sitter/tree-sitter-rust", rev = "a360da0a29a19c281d08295a35ecd0544d2da211" } diff --git a/helix-core/tests/data/indent/rust.rs b/helix-core/tests/data/indent/rust.rs new file mode 100644 index 000000000..010745e0d --- /dev/null +++ b/helix-core/tests/data/indent/rust.rs @@ -0,0 +1,105 @@ +use std::{ + io::{self, stdout, Stdout, Write}, + path::PathBuf, + sync::Arc, + time::Duration, +}; +mod test { + fn hello_world() { + 1 + 1; + + let does_indentation_work = 1; + + let mut really_long_variable_name_using_up_the_line = + really_long_fn_that_should_definitely_go_on_the_next_line(); + really_long_variable_name_using_up_the_line = + really_long_fn_that_should_definitely_go_on_the_next_line(); + really_long_variable_name_using_up_the_line |= + really_long_fn_that_should_definitely_go_on_the_next_line(); + + let ( + a_long_variable_name_in_this_tuple, + b_long_variable_name_in_this_tuple, + c_long_variable_name_in_this_tuple, + d_long_variable_name_in_this_tuple, + e_long_variable_name_in_this_tuple, + ): (usize, usize, usize, usize, usize) = + if really_long_fn_that_should_definitely_go_on_the_next_line() { + ( + 03294239434, + 1213412342314, + 21231234134, + 834534234549898789, + 9879234234543853457, + ) + } else { + (0, 1, 2, 3, 4) + }; + + let test_function = function_with_param(this_param, + that_param + ); + + let test_function = function_with_param( + this_param, + that_param + ); + + let test_function = function_with_proper_indent(param1, + param2, + ); + + let selection = Selection::new( + changes + .clone() + .map(|(start, end, text): (usize, usize, Option)| { + let len = text.map(|text| text.len()).unwrap() - 1; // minus newline + let pos = start + len; + Range::new(pos, pos) + }) + .collect(), + 0, + ); + + return; + } +} + +impl MyTrait for YourType +where + A: TraitB + TraitC, + D: TraitE + TraitF, +{ + +} +#[test] +// +match test { + Some(a) => 1, + None => { + unimplemented!() + } +} +std::panic::set_hook(Box::new(move |info| { + hook(info); +})); + +{ { { + 1 +}}} + +pub fn change(document: &Document, changes: I) -> Self +where + I: IntoIterator + ExactSizeIterator, +{ + [ + 1, + 2, + 3, + ]; + ( + 1, + 2 + ); + true +} diff --git a/helix-core/tests/indent.rs b/helix-core/tests/indent.rs new file mode 100644 index 000000000..ff04d05f5 --- /dev/null +++ b/helix-core/tests/indent.rs @@ -0,0 +1,68 @@ +use helix_core::{ + indent::{treesitter_indent_for_pos, IndentStyle}, + syntax::Loader, + Syntax, +}; +use std::path::PathBuf; + +#[test] +fn test_treesitter_indent_rust() { + test_treesitter_indent("rust.rs", "source.rust"); +} +#[test] +fn test_treesitter_indent_rust_2() { + test_treesitter_indent("indent.rs", "source.rust"); + // TODO Use commands.rs as indentation test. + // Currently this fails because we can't align the parameters of a closure yet + // test_treesitter_indent("commands.rs", "source.rust"); +} + +fn test_treesitter_indent(file_name: &str, lang_scope: &str) { + let mut test_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + test_dir.push("tests/data/indent"); + + let mut test_file = test_dir.clone(); + test_file.push(file_name); + let test_file = std::fs::File::open(test_file).unwrap(); + let doc = ropey::Rope::from_reader(test_file).unwrap(); + + let mut config_file = test_dir; + config_file.push("languages.toml"); + let config = std::fs::read(config_file).unwrap(); + let config = toml::from_slice(&config).unwrap(); + let loader = Loader::new(config); + + // set runtime path so we can find the queries + let mut runtime = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + runtime.push("../runtime"); + std::env::set_var("HELIX_RUNTIME", runtime.to_str().unwrap()); + + let language_config = loader.language_config_for_scope(lang_scope).unwrap(); + let highlight_config = language_config.highlight_config(&[]).unwrap(); + let syntax = Syntax::new(&doc, highlight_config, std::sync::Arc::new(loader)); + let indent_query = language_config.indent_query().unwrap(); + let text = doc.slice(..); + + for i in 0..doc.len_lines() { + let line = text.line(i); + if let Some(pos) = helix_core::find_first_non_whitespace_char(line) { + let suggested_indent = treesitter_indent_for_pos( + indent_query, + &syntax, + &IndentStyle::Spaces(4), + text, + i, + text.line_to_char(i) + pos, + false, + ) + .unwrap(); + assert!( + line.get_slice(..pos).map_or(false, |s| s == suggested_indent), + "Wrong indentation on line {}:\n\"{}\" (original line)\n\"{}\" (suggested indentation)\n", + i+1, + line.slice(..line.len_chars()-1), + suggested_indent, + ); + } + } +} diff --git a/helix-dap/Cargo.toml b/helix-dap/Cargo.toml index 24288697e..95a059052 100644 --- a/helix-dap/Cargo.toml +++ b/helix-dap/Cargo.toml @@ -19,6 +19,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "1.0" tokio = { version = "1", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot", "net", "sync"] } +which = "4.2" [dev-dependencies] fern = "0.6" diff --git a/helix-dap/src/client.rs b/helix-dap/src/client.rs index 562544296..9498c64c1 100644 --- a/helix-dap/src/client.rs +++ b/helix-dap/src/client.rs @@ -105,6 +105,9 @@ impl Client { args: Vec<&str>, id: usize, ) -> Result<(Self, UnboundedReceiver)> { + // Resolve path to the binary + let cmd = which::which(cmd).map_err(|err| anyhow::anyhow!(err))?; + let process = Command::new(cmd) .args(args) .stdin(Stdio::piped()) diff --git a/helix-syntax/Cargo.toml b/helix-loader/Cargo.toml similarity index 60% rename from helix-syntax/Cargo.toml rename to helix-loader/Cargo.toml index 855839be0..21b37333a 100644 --- a/helix-syntax/Cargo.toml +++ b/helix-loader/Cargo.toml @@ -1,21 +1,23 @@ [package] -name = "helix-syntax" +name = "helix-loader" version = "0.6.0" +description = "A post-modern text editor." authors = ["Blaž Hrastnik "] edition = "2021" license = "MPL-2.0" -description = "Tree-sitter grammars support" categories = ["editor"] repository = "https://github.com/helix-editor/helix" homepage = "https://helix-editor.com" -include = ["src/**/*", "languages/**/*", "build.rs", "!**/docs/**/*", "!**/test/**/*", "!**/examples/**/*", "!**/build/**/*"] [dependencies] +anyhow = "1" +serde = { version = "1.0", features = ["derive"] } +toml = "0.5" +etcetera = "0.3" tree-sitter = "0.20" libloading = "0.7" -anyhow = "1" +once_cell = "1.9" -[build-dependencies] +# cloning/compiling tree-sitter grammars cc = { version = "1" } threadpool = { version = "1.0" } -anyhow = "1" diff --git a/helix-loader/build.rs b/helix-loader/build.rs new file mode 100644 index 000000000..e0ebd1c48 --- /dev/null +++ b/helix-loader/build.rs @@ -0,0 +1,6 @@ +fn main() { + println!( + "cargo:rustc-env=BUILD_TARGET={}", + std::env::var("TARGET").unwrap() + ); +} diff --git a/helix-loader/src/grammar.rs b/helix-loader/src/grammar.rs new file mode 100644 index 000000000..f0c5ea282 --- /dev/null +++ b/helix-loader/src/grammar.rs @@ -0,0 +1,391 @@ +use anyhow::{anyhow, Context, Result}; +use libloading::{Library, Symbol}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::time::SystemTime; +use std::{ + collections::HashSet, + path::{Path, PathBuf}, + process::Command, + sync::mpsc::channel, +}; +use tree_sitter::Language; + +#[cfg(unix)] +const DYLIB_EXTENSION: &str = "so"; + +#[cfg(windows)] +const DYLIB_EXTENSION: &str = "dll"; + +#[derive(Debug, Serialize, Deserialize)] +struct Configuration { + #[serde(rename = "use-grammars")] + pub grammar_selection: Option, + pub grammar: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "lowercase", untagged)] +pub enum GrammarSelection { + Only { only: HashSet }, + Except { except: HashSet }, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GrammarConfiguration { + #[serde(rename = "name")] + pub grammar_id: String, + pub source: GrammarSource, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "lowercase", untagged)] +pub enum GrammarSource { + Local { + path: String, + }, + Git { + #[serde(rename = "git")] + remote: String, + #[serde(rename = "rev")] + revision: String, + subpath: Option, + }, +} + +const BUILD_TARGET: &str = env!("BUILD_TARGET"); +const REMOTE_NAME: &str = "origin"; + +pub fn get_language(name: &str) -> Result { + let name = name.to_ascii_lowercase(); + let mut library_path = crate::runtime_dir().join("grammars").join(&name); + library_path.set_extension(DYLIB_EXTENSION); + + let library = unsafe { Library::new(&library_path) } + .with_context(|| format!("Error opening dynamic library {:?}", library_path))?; + let language_fn_name = format!("tree_sitter_{}", name.replace('-', "_")); + let language = unsafe { + let language_fn: Symbol Language> = library + .get(language_fn_name.as_bytes()) + .with_context(|| format!("Failed to load symbol {}", language_fn_name))?; + language_fn() + }; + std::mem::forget(library); + Ok(language) +} + +pub fn fetch_grammars() -> Result<()> { + // We do not need to fetch local grammars. + let mut grammars = get_grammar_configs()?; + grammars.retain(|grammar| !matches!(grammar.source, GrammarSource::Local { .. })); + + run_parallel(grammars, fetch_grammar, "fetch") +} + +pub fn build_grammars() -> Result<()> { + run_parallel(get_grammar_configs()?, build_grammar, "build") +} + +// Returns the set of grammar configurations the user requests. +// Grammars are configured in the default and user `languages.toml` and are +// merged. The `grammar_selection` key of the config is then used to filter +// down all grammars into a subset of the user's choosing. +fn get_grammar_configs() -> Result> { + let config: Configuration = crate::user_lang_config() + .context("Could not parse languages.toml")? + .try_into()?; + + let grammars = match config.grammar_selection { + Some(GrammarSelection::Only { only: selections }) => config + .grammar + .into_iter() + .filter(|grammar| selections.contains(&grammar.grammar_id)) + .collect(), + Some(GrammarSelection::Except { except: rejections }) => config + .grammar + .into_iter() + .filter(|grammar| !rejections.contains(&grammar.grammar_id)) + .collect(), + None => config.grammar, + }; + + Ok(grammars) +} + +fn run_parallel(grammars: Vec, job: F, action: &'static str) -> Result<()> +where + F: Fn(GrammarConfiguration) -> Result<()> + std::marker::Send + 'static + Copy, +{ + let pool = threadpool::Builder::new().build(); + let (tx, rx) = channel(); + + for grammar in grammars { + let tx = tx.clone(); + + pool.execute(move || { + tx.send(job(grammar)).unwrap(); + }); + } + + drop(tx); + + // TODO: print all failures instead of the first one found. + rx.iter() + .find(|result| result.is_err()) + .map(|err| err.with_context(|| format!("Failed to {} some grammar(s)", action))) + .unwrap_or(Ok(())) +} + +fn fetch_grammar(grammar: GrammarConfiguration) -> Result<()> { + if let GrammarSource::Git { + remote, revision, .. + } = grammar.source + { + let grammar_dir = crate::runtime_dir() + .join("grammars/sources") + .join(&grammar.grammar_id); + + fs::create_dir_all(&grammar_dir).context(format!( + "Could not create grammar directory {:?}", + grammar_dir + ))?; + + // create the grammar dir contains a git directory + if !grammar_dir.join(".git").is_dir() { + git(&grammar_dir, ["init"])?; + } + + // ensure the remote matches the configured remote + if get_remote_url(&grammar_dir).map_or(true, |s| s != remote) { + set_remote(&grammar_dir, &remote)?; + } + + // ensure the revision matches the configured revision + if get_revision(&grammar_dir).map_or(true, |s| s != revision) { + // Fetch the exact revision from the remote. + // Supported by server-side git since v2.5.0 (July 2015), + // enabled by default on major git hosts. + git( + &grammar_dir, + ["fetch", "--depth", "1", REMOTE_NAME, &revision], + )?; + git(&grammar_dir, ["checkout", &revision])?; + + println!( + "Grammar '{}' checked out at '{}'.", + grammar.grammar_id, revision + ); + } else { + println!("Grammar '{}' is already up to date.", grammar.grammar_id); + } + } + + Ok(()) +} + +// Sets the remote for a repository to the given URL, creating the remote if +// it does not yet exist. +fn set_remote(repository_dir: &Path, remote_url: &str) -> Result { + git( + repository_dir, + ["remote", "set-url", REMOTE_NAME, remote_url], + ) + .or_else(|_| git(repository_dir, ["remote", "add", REMOTE_NAME, remote_url])) +} + +fn get_remote_url(repository_dir: &Path) -> Option { + git(repository_dir, ["remote", "get-url", REMOTE_NAME]).ok() +} + +fn get_revision(repository_dir: &Path) -> Option { + git(repository_dir, ["rev-parse", "HEAD"]).ok() +} + +// A wrapper around 'git' commands which returns stdout in success and a +// helpful error message showing the command, stdout, and stderr in error. +fn git(repository_dir: &Path, args: I) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let output = Command::new("git") + .args(args) + .current_dir(repository_dir) + .output()?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout) + .trim_end() + .to_owned()) + } else { + // TODO: figure out how to display the git command using `args` + Err(anyhow!( + "Git command failed.\nStdout: {}\nStderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + )) + } +} + +fn build_grammar(grammar: GrammarConfiguration) -> Result<()> { + let grammar_dir = if let GrammarSource::Local { path } = &grammar.source { + PathBuf::from(&path) + } else { + crate::runtime_dir() + .join("grammars/sources") + .join(&grammar.grammar_id) + }; + + let grammar_dir_entries = grammar_dir.read_dir().with_context(|| { + format!( + "Failed to read directory {:?}. Did you use 'hx --grammar fetch'?", + grammar_dir + ) + })?; + + if grammar_dir_entries.count() == 0 { + return Err(anyhow!( + "Directory {:?} is empty. Did you use 'hx --grammar fetch'?", + grammar_dir + )); + }; + + let path = match &grammar.source { + GrammarSource::Git { + subpath: Some(subpath), + .. + } => grammar_dir.join(subpath), + _ => grammar_dir, + } + .join("src"); + + build_tree_sitter_library(&path, grammar) +} + +fn build_tree_sitter_library(src_path: &Path, grammar: GrammarConfiguration) -> Result<()> { + let header_path = src_path; + let parser_path = src_path.join("parser.c"); + let mut scanner_path = src_path.join("scanner.c"); + + let scanner_path = if scanner_path.exists() { + Some(scanner_path) + } else { + scanner_path.set_extension("cc"); + if scanner_path.exists() { + Some(scanner_path) + } else { + None + } + }; + let parser_lib_path = crate::runtime_dir().join("grammars"); + let mut library_path = parser_lib_path.join(&grammar.grammar_id); + library_path.set_extension(DYLIB_EXTENSION); + + let recompile = needs_recompile(&library_path, &parser_path, &scanner_path) + .context("Failed to compare source and binary timestamps")?; + + if !recompile { + println!("Grammar '{}' is already built.", grammar.grammar_id); + return Ok(()); + } + + println!("Building grammar '{}'", grammar.grammar_id); + + let mut config = cc::Build::new(); + config + .cpp(true) + .opt_level(3) + .cargo_metadata(false) + .host(BUILD_TARGET) + .target(BUILD_TARGET); + let compiler = config.get_compiler(); + let mut command = Command::new(compiler.path()); + command.current_dir(src_path); + for (key, value) in compiler.env() { + command.env(key, value); + } + + if cfg!(windows) { + command + .args(&["/nologo", "/LD", "/I"]) + .arg(header_path) + .arg("/Od") + .arg("/utf-8"); + if let Some(scanner_path) = scanner_path.as_ref() { + command.arg(scanner_path); + } + + command + .arg(parser_path) + .arg("/link") + .arg(format!("/out:{}", library_path.to_str().unwrap())); + } else { + command + .arg("-shared") + .arg("-fPIC") + .arg("-fno-exceptions") + .arg("-g") + .arg("-I") + .arg(header_path) + .arg("-o") + .arg(&library_path) + .arg("-O3"); + if let Some(scanner_path) = scanner_path.as_ref() { + if scanner_path.extension() == Some("c".as_ref()) { + command.arg("-xc").arg("-std=c99").arg(scanner_path); + } else { + command.arg(scanner_path); + } + } + command.arg("-xc").arg(parser_path); + if cfg!(all(unix, not(target_os = "macos"))) { + command.arg("-Wl,-z,relro,-z,now"); + } + } + + let output = command.output().context("Failed to execute C compiler")?; + if !output.status.success() { + return Err(anyhow!( + "Parser compilation failed.\nStdout: {}\nStderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + )); + } + + Ok(()) +} + +fn needs_recompile( + lib_path: &Path, + parser_c_path: &Path, + scanner_path: &Option, +) -> Result { + if !lib_path.exists() { + return Ok(true); + } + let lib_mtime = mtime(lib_path)?; + if mtime(parser_c_path)? > lib_mtime { + return Ok(true); + } + if let Some(scanner_path) = scanner_path { + if mtime(scanner_path)? > lib_mtime { + return Ok(true); + } + } + Ok(false) +} + +fn mtime(path: &Path) -> Result { + Ok(fs::metadata(path)?.modified()?) +} + +/// Gives the contents of a file from a language's `runtime/queries/` +/// directory +pub fn load_runtime_file(language: &str, filename: &str) -> Result { + let path = crate::RUNTIME_DIR + .join("queries") + .join(language) + .join(filename); + std::fs::read_to_string(&path) +} diff --git a/helix-loader/src/lib.rs b/helix-loader/src/lib.rs new file mode 100644 index 000000000..a2c4d96f0 --- /dev/null +++ b/helix-loader/src/lib.rs @@ -0,0 +1,161 @@ +pub mod grammar; + +use etcetera::base_strategy::{choose_base_strategy, BaseStrategy}; + +pub static RUNTIME_DIR: once_cell::sync::Lazy = + once_cell::sync::Lazy::new(runtime_dir); + +pub fn runtime_dir() -> std::path::PathBuf { + if let Ok(dir) = std::env::var("HELIX_RUNTIME") { + return dir.into(); + } + + const RT_DIR: &str = "runtime"; + let conf_dir = config_dir().join(RT_DIR); + if conf_dir.exists() { + return conf_dir; + } + + if let Ok(dir) = std::env::var("CARGO_MANIFEST_DIR") { + // this is the directory of the crate being run by cargo, we need the workspace path so we take the parent + return std::path::PathBuf::from(dir).parent().unwrap().join(RT_DIR); + } + + // fallback to location of the executable being run + std::env::current_exe() + .ok() + .and_then(|path| path.parent().map(|path| path.to_path_buf().join(RT_DIR))) + .unwrap() +} + +pub fn config_dir() -> std::path::PathBuf { + // TODO: allow env var override + let strategy = choose_base_strategy().expect("Unable to find the config directory!"); + let mut path = strategy.config_dir(); + path.push("helix"); + path +} + +pub fn cache_dir() -> std::path::PathBuf { + // TODO: allow env var override + let strategy = choose_base_strategy().expect("Unable to find the config directory!"); + let mut path = strategy.cache_dir(); + path.push("helix"); + path +} + +pub fn config_file() -> std::path::PathBuf { + config_dir().join("config.toml") +} + +pub fn lang_config_file() -> std::path::PathBuf { + config_dir().join("languages.toml") +} + +pub fn log_file() -> std::path::PathBuf { + cache_dir().join("helix.log") +} + +/// Default bultin-in languages.toml. +pub fn default_lang_config() -> toml::Value { + toml::from_slice(include_bytes!("../../languages.toml")) + .expect("Could not parse bultin-in languages.toml to valid toml") +} + +/// User configured languages.toml file, merged with the default config. +pub fn user_lang_config() -> Result { + let def_lang_conf = default_lang_config(); + let data = std::fs::read(crate::config_dir().join("languages.toml")); + let user_lang_conf = match data { + Ok(raw) => { + let value = toml::from_slice(&raw)?; + merge_toml_values(def_lang_conf, value) + } + Err(_) => def_lang_conf, + }; + + Ok(user_lang_conf) +} + +// right overrides left +pub fn merge_toml_values(left: toml::Value, right: toml::Value) -> toml::Value { + use toml::Value; + + fn get_name(v: &Value) -> Option<&str> { + v.get("name").and_then(Value::as_str) + } + + match (left, right) { + (Value::Array(mut left_items), Value::Array(right_items)) => { + left_items.reserve(right_items.len()); + for rvalue in right_items { + let lvalue = get_name(&rvalue) + .and_then(|rname| left_items.iter().position(|v| get_name(v) == Some(rname))) + .map(|lpos| left_items.remove(lpos)); + let mvalue = match lvalue { + Some(lvalue) => merge_toml_values(lvalue, rvalue), + None => rvalue, + }; + left_items.push(mvalue); + } + Value::Array(left_items) + } + (Value::Table(mut left_map), Value::Table(right_map)) => { + for (rname, rvalue) in right_map { + match left_map.remove(&rname) { + Some(lvalue) => { + let merged_value = merge_toml_values(lvalue, rvalue); + left_map.insert(rname, merged_value); + } + None => { + left_map.insert(rname, rvalue); + } + } + } + Value::Table(left_map) + } + // Catch everything else we didn't handle, and use the right value + (_, value) => value, + } +} + +#[cfg(test)] +mod merge_toml_tests { + use super::merge_toml_values; + + #[test] + fn language_tomls() { + use toml::Value; + + const USER: &str = " + [[language]] + name = \"nix\" + test = \"bbb\" + indent = { tab-width = 4, unit = \" \", test = \"aaa\" } + "; + + let base: Value = toml::from_slice(include_bytes!("../../languages.toml")) + .expect("Couldn't parse built-in languages config"); + let user: Value = toml::from_str(USER).unwrap(); + + let merged = merge_toml_values(base, user); + let languages = merged.get("language").unwrap().as_array().unwrap(); + let nix = languages + .iter() + .find(|v| v.get("name").unwrap().as_str().unwrap() == "nix") + .unwrap(); + let nix_indent = nix.get("indent").unwrap(); + + // We changed tab-width and unit in indent so check them if they are the new values + assert_eq!( + nix_indent.get("tab-width").unwrap().as_integer().unwrap(), + 4 + ); + assert_eq!(nix_indent.get("unit").unwrap().as_str().unwrap(), " "); + // We added a new keys, so check them + assert_eq!(nix.get("test").unwrap().as_str().unwrap(), "bbb"); + assert_eq!(nix_indent.get("test").unwrap().as_str().unwrap(), "aaa"); + // We didn't change comment-token so it should be same + assert_eq!(nix.get("comment-token").unwrap().as_str().unwrap(), "#"); + } +} diff --git a/helix-lsp/Cargo.toml b/helix-lsp/Cargo.toml index 39b537063..755f49b5c 100644 --- a/helix-lsp/Cargo.toml +++ b/helix-lsp/Cargo.toml @@ -23,5 +23,6 @@ lsp-types = { version = "0.92", features = ["proposed"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "1.0" -tokio = { version = "1.16", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot", "sync"] } +tokio = { version = "1.17", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot", "sync"] } tokio-stream = "0.1.8" +which = "4.2" diff --git a/helix-lsp/src/client.rs b/helix-lsp/src/client.rs index 15cbca0eb..1ce5158be 100644 --- a/helix-lsp/src/client.rs +++ b/helix-lsp/src/client.rs @@ -43,6 +43,9 @@ impl Client { root_markers: Vec, id: usize, ) -> Result<(Self, UnboundedReceiver<(usize, Call)>, Arc)> { + // Resolve path to the binary + let cmd = which::which(cmd).map_err(|err| anyhow::anyhow!(err))?; + let process = Command::new(cmd) .args(args) .stdin(Stdio::piped()) @@ -110,6 +113,10 @@ impl Client { self.offset_encoding } + pub fn config(&self) -> Option<&Value> { + self.config.as_ref() + } + /// Execute a RPC request on the language server. async fn request(&self, params: R::Params) -> Result where @@ -243,6 +250,13 @@ impl Client { root_uri: root, initialization_options: self.config.clone(), capabilities: lsp::ClientCapabilities { + workspace: Some(lsp::WorkspaceClientCapabilities { + configuration: Some(true), + did_change_configuration: Some(lsp::DynamicRegistrationClientCapabilities { + dynamic_registration: Some(false), + }), + ..Default::default() + }), text_document: Some(lsp::TextDocumentClientCapabilities { completion: Some(lsp::CompletionClientCapabilities { completion_item: Some(lsp::CompletionItemCapability { @@ -327,6 +341,16 @@ impl Client { self.exit().await } + // ------------------------------------------------------------------------------------------- + // Workspace + // ------------------------------------------------------------------------------------------- + + pub fn did_change_configuration(&self, settings: Value) -> impl Future> { + self.notify::( + lsp::DidChangeConfigurationParams { settings }, + ) + } + // ------------------------------------------------------------------------------------------- // Text document // ------------------------------------------------------------------------------------------- diff --git a/helix-lsp/src/lib.rs b/helix-lsp/src/lib.rs index 109546d05..389dfb4dd 100644 --- a/helix-lsp/src/lib.rs +++ b/helix-lsp/src/lib.rs @@ -191,6 +191,7 @@ pub mod util { pub enum MethodCall { WorkDoneProgressCreate(lsp::WorkDoneProgressCreateParams), ApplyWorkspaceEdit(lsp::ApplyWorkspaceEditParams), + WorkspaceConfiguration(lsp::ConfigurationParams), } impl MethodCall { @@ -209,6 +210,12 @@ impl MethodCall { .expect("Failed to parse ApplyWorkspaceEdit params"); Self::ApplyWorkspaceEdit(params) } + lsp::request::WorkspaceConfiguration::METHOD => { + let params: lsp::ConfigurationParams = params + .parse() + .expect("Failed to parse WorkspaceConfiguration params"); + Self::WorkspaceConfiguration(params) + } _ => { log::warn!("unhandled lsp request: {}", method); return None; diff --git a/helix-syntax/README.md b/helix-syntax/README.md deleted file mode 100644 index bba2197a3..000000000 --- a/helix-syntax/README.md +++ /dev/null @@ -1,13 +0,0 @@ -helix-syntax -============ - -Syntax highlighting for helix, (shallow) submodules resides here. - -Differences from nvim-treesitter --------------------------------- - -As the syntax are commonly ported from -. - -Note that we do not support the custom `#any-of` predicate which is -supported by neovim so one needs to change it to `#match` with regex. diff --git a/helix-syntax/build.rs b/helix-syntax/build.rs deleted file mode 100644 index fa8be8b38..000000000 --- a/helix-syntax/build.rs +++ /dev/null @@ -1,206 +0,0 @@ -use anyhow::{anyhow, Context, Result}; -use std::fs; -use std::time::SystemTime; -use std::{ - path::{Path, PathBuf}, - process::Command, -}; - -use std::sync::mpsc::channel; - -fn collect_tree_sitter_dirs(ignore: &[String]) -> Result> { - let mut dirs = Vec::new(); - let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("languages"); - - for entry in fs::read_dir(path)? { - let entry = entry?; - let path = entry.path(); - - if !entry.file_type()?.is_dir() { - continue; - } - - let dir = path.file_name().unwrap().to_str().unwrap().to_string(); - - // filter ignores - if ignore.contains(&dir) { - continue; - } - dirs.push(dir) - } - - Ok(dirs) -} - -#[cfg(unix)] -const DYLIB_EXTENSION: &str = "so"; - -#[cfg(windows)] -const DYLIB_EXTENSION: &str = "dll"; - -fn build_library(src_path: &Path, language: &str) -> Result<()> { - let header_path = src_path; - // let grammar_path = src_path.join("grammar.json"); - let parser_path = src_path.join("parser.c"); - let mut scanner_path = src_path.join("scanner.c"); - - let scanner_path = if scanner_path.exists() { - Some(scanner_path) - } else { - scanner_path.set_extension("cc"); - if scanner_path.exists() { - Some(scanner_path) - } else { - None - } - }; - let parser_lib_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../runtime/grammars"); - let mut library_path = parser_lib_path.join(language); - library_path.set_extension(DYLIB_EXTENSION); - - let recompile = needs_recompile(&library_path, &parser_path, &scanner_path) - .with_context(|| "Failed to compare source and binary timestamps")?; - - if !recompile { - return Ok(()); - } - let mut config = cc::Build::new(); - config.cpp(true).opt_level(2).cargo_metadata(false); - let compiler = config.get_compiler(); - let mut command = Command::new(compiler.path()); - command.current_dir(src_path); - for (key, value) in compiler.env() { - command.env(key, value); - } - - if cfg!(windows) { - command - .args(&["/nologo", "/LD", "/I"]) - .arg(header_path) - .arg("/Od") - .arg("/utf-8"); - if let Some(scanner_path) = scanner_path.as_ref() { - command.arg(scanner_path); - } - - command - .arg(parser_path) - .arg("/link") - .arg(format!("/out:{}", library_path.to_str().unwrap())); - } else { - command - .arg("-shared") - .arg("-fPIC") - .arg("-fno-exceptions") - .arg("-g") - .arg("-I") - .arg(header_path) - .arg("-o") - .arg(&library_path) - .arg("-O2"); - if let Some(scanner_path) = scanner_path.as_ref() { - if scanner_path.extension() == Some("c".as_ref()) { - command.arg("-xc").arg("-std=c99").arg(scanner_path); - } else { - command.arg(scanner_path); - } - } - command.arg("-xc").arg(parser_path); - if cfg!(all(unix, not(target_os = "macos"))) { - command.arg("-Wl,-z,relro,-z,now"); - } - } - - let output = command - .output() - .with_context(|| "Failed to execute C compiler")?; - if !output.status.success() { - return Err(anyhow!( - "Parser compilation failed.\nStdout: {}\nStderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - )); - } - - Ok(()) -} -fn needs_recompile( - lib_path: &Path, - parser_c_path: &Path, - scanner_path: &Option, -) -> Result { - if !lib_path.exists() { - return Ok(true); - } - let lib_mtime = mtime(lib_path)?; - if mtime(parser_c_path)? > lib_mtime { - return Ok(true); - } - if let Some(scanner_path) = scanner_path { - if mtime(scanner_path)? > lib_mtime { - return Ok(true); - } - } - Ok(false) -} - -fn mtime(path: &Path) -> Result { - Ok(fs::metadata(path)?.modified()?) -} - -fn build_dir(dir: &str, language: &str) { - println!("Build language {}", language); - if PathBuf::from("languages") - .join(dir) - .read_dir() - .unwrap() - .next() - .is_none() - { - eprintln!( - "The directory {} is empty, you probably need to use 'git submodule update --init --recursive'?", - dir - ); - std::process::exit(1); - } - - let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("languages") - .join(dir) - .join("src"); - - build_library(&path, language).unwrap(); -} - -fn main() { - let ignore = vec![ - "tree-sitter-typescript".to_string(), - "tree-sitter-ocaml".to_string(), - ]; - let dirs = collect_tree_sitter_dirs(&ignore).unwrap(); - - let mut n_jobs = 0; - let pool = threadpool::Builder::new().build(); // by going through the builder, it'll use num_cpus - let (tx, rx) = channel(); - - for dir in dirs { - let tx = tx.clone(); - n_jobs += 1; - - pool.execute(move || { - let language = &dir.strip_prefix("tree-sitter-").unwrap(); - build_dir(&dir, language); - - // report progress - tx.send(1).unwrap(); - }); - } - pool.join(); - // drop(tx); - assert_eq!(rx.try_iter().sum::(), n_jobs); - - build_dir("tree-sitter-typescript/tsx", "tsx"); - build_dir("tree-sitter-typescript/typescript", "typescript"); - build_dir("tree-sitter-ocaml/ocaml", "ocaml"); - build_dir("tree-sitter-ocaml/interface", "ocaml-interface") -} diff --git a/helix-syntax/languages/tree-sitter-agda b/helix-syntax/languages/tree-sitter-agda deleted file mode 160000 index ca69cdf48..000000000 --- a/helix-syntax/languages/tree-sitter-agda +++ /dev/null @@ -1 +0,0 @@ -Subproject commit ca69cdf485e9ce2b2ef0991a720aa88d87d30231 diff --git a/helix-syntax/languages/tree-sitter-bash b/helix-syntax/languages/tree-sitter-bash deleted file mode 160000 index a8eb5cb57..000000000 --- a/helix-syntax/languages/tree-sitter-bash +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a8eb5cb57c66f74c63ab950de081207cccf52017 diff --git a/helix-syntax/languages/tree-sitter-c b/helix-syntax/languages/tree-sitter-c deleted file mode 160000 index f05e279ae..000000000 --- a/helix-syntax/languages/tree-sitter-c +++ /dev/null @@ -1 +0,0 @@ -Subproject commit f05e279aedde06a25801c3f2b2cc8ac17fac52ae diff --git a/helix-syntax/languages/tree-sitter-c-sharp b/helix-syntax/languages/tree-sitter-c-sharp deleted file mode 160000 index 53a65a908..000000000 --- a/helix-syntax/languages/tree-sitter-c-sharp +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 53a65a908167d6556e1fcdb67f1ee62aac101dda diff --git a/helix-syntax/languages/tree-sitter-cmake b/helix-syntax/languages/tree-sitter-cmake deleted file mode 160000 index f6616f1e4..000000000 --- a/helix-syntax/languages/tree-sitter-cmake +++ /dev/null @@ -1 +0,0 @@ -Subproject commit f6616f1e417ee8b62daf251aa1daa5d73781c596 diff --git a/helix-syntax/languages/tree-sitter-comment b/helix-syntax/languages/tree-sitter-comment deleted file mode 160000 index 5dd3c62f1..000000000 --- a/helix-syntax/languages/tree-sitter-comment +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 5dd3c62f1bbe378b220fe16b317b85247898639e diff --git a/helix-syntax/languages/tree-sitter-cpp b/helix-syntax/languages/tree-sitter-cpp deleted file mode 160000 index e8dcc9d2b..000000000 --- a/helix-syntax/languages/tree-sitter-cpp +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e8dcc9d2b404c542fd236ea5f7208f90be8a6e89 diff --git a/helix-syntax/languages/tree-sitter-css b/helix-syntax/languages/tree-sitter-css deleted file mode 160000 index 94e102309..000000000 --- a/helix-syntax/languages/tree-sitter-css +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 94e10230939e702b4fa3fa2cb5c3bc7173b95d07 diff --git a/helix-syntax/languages/tree-sitter-dart b/helix-syntax/languages/tree-sitter-dart deleted file mode 160000 index 6a2537668..000000000 --- a/helix-syntax/languages/tree-sitter-dart +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 6a25376685d1d47968c2cef06d4db8d84a70025e diff --git a/helix-syntax/languages/tree-sitter-dockerfile b/helix-syntax/languages/tree-sitter-dockerfile deleted file mode 160000 index 7af32bc04..000000000 --- a/helix-syntax/languages/tree-sitter-dockerfile +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 7af32bc04a66ab196f5b9f92ac471f29372ae2ce diff --git a/helix-syntax/languages/tree-sitter-elixir b/helix-syntax/languages/tree-sitter-elixir deleted file mode 160000 index f5d7bda54..000000000 --- a/helix-syntax/languages/tree-sitter-elixir +++ /dev/null @@ -1 +0,0 @@ -Subproject commit f5d7bda543da788bd507b05bd722627dde66c9ec diff --git a/helix-syntax/languages/tree-sitter-elm b/helix-syntax/languages/tree-sitter-elm deleted file mode 160000 index bd50ccf66..000000000 --- a/helix-syntax/languages/tree-sitter-elm +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bd50ccf66b42c55252ac8efc1086af4ac6bab8cd diff --git a/helix-syntax/languages/tree-sitter-erlang b/helix-syntax/languages/tree-sitter-erlang deleted file mode 160000 index 86985bde3..000000000 --- a/helix-syntax/languages/tree-sitter-erlang +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 86985bde399c5f40b00bc75f7ab70a6c69a5f9c3 diff --git a/helix-syntax/languages/tree-sitter-fish b/helix-syntax/languages/tree-sitter-fish deleted file mode 160000 index 04e54ab65..000000000 --- a/helix-syntax/languages/tree-sitter-fish +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 04e54ab6585dfd4fee6ddfe5849af56f101b6d4f diff --git a/helix-syntax/languages/tree-sitter-git-commit b/helix-syntax/languages/tree-sitter-git-commit deleted file mode 160000 index 066e395e1..000000000 --- a/helix-syntax/languages/tree-sitter-git-commit +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 066e395e1107df17183cf3ae4230f1a1406cc972 diff --git a/helix-syntax/languages/tree-sitter-git-config b/helix-syntax/languages/tree-sitter-git-config deleted file mode 160000 index 0e4f0baf9..000000000 --- a/helix-syntax/languages/tree-sitter-git-config +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 0e4f0baf90b57e5aeb62dcdbf03062c6315d43ea diff --git a/helix-syntax/languages/tree-sitter-git-diff b/helix-syntax/languages/tree-sitter-git-diff deleted file mode 160000 index c12e6ecb5..000000000 --- a/helix-syntax/languages/tree-sitter-git-diff +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c12e6ecb54485f764250556ffd7ccb18f8e2942b diff --git a/helix-syntax/languages/tree-sitter-git-rebase b/helix-syntax/languages/tree-sitter-git-rebase deleted file mode 160000 index 332dc528f..000000000 --- a/helix-syntax/languages/tree-sitter-git-rebase +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 332dc528f27044bc4427024dbb33e6941fc131f2 diff --git a/helix-syntax/languages/tree-sitter-glsl b/helix-syntax/languages/tree-sitter-glsl deleted file mode 160000 index 88408ffc5..000000000 --- a/helix-syntax/languages/tree-sitter-glsl +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 88408ffc5e27abcffced7010fc77396ae3636d7e diff --git a/helix-syntax/languages/tree-sitter-go b/helix-syntax/languages/tree-sitter-go deleted file mode 160000 index 0fa917a70..000000000 --- a/helix-syntax/languages/tree-sitter-go +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 0fa917a7022d1cd2e9b779a6a8fc5dc7fad69c75 diff --git a/helix-syntax/languages/tree-sitter-graphql b/helix-syntax/languages/tree-sitter-graphql deleted file mode 160000 index 5e66e961e..000000000 --- a/helix-syntax/languages/tree-sitter-graphql +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 5e66e961eee421786bdda8495ed1db045e06b5fe diff --git a/helix-syntax/languages/tree-sitter-haskell b/helix-syntax/languages/tree-sitter-haskell deleted file mode 160000 index b6ec26f18..000000000 --- a/helix-syntax/languages/tree-sitter-haskell +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b6ec26f181dd059eedd506fa5fbeae1b8e5556c8 diff --git a/helix-syntax/languages/tree-sitter-html b/helix-syntax/languages/tree-sitter-html deleted file mode 160000 index d93af487c..000000000 --- a/helix-syntax/languages/tree-sitter-html +++ /dev/null @@ -1 +0,0 @@ -Subproject commit d93af487cc75120c89257195e6be46c999c6ba18 diff --git a/helix-syntax/languages/tree-sitter-iex b/helix-syntax/languages/tree-sitter-iex deleted file mode 160000 index 3ec55082c..000000000 --- a/helix-syntax/languages/tree-sitter-iex +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 3ec55082cf0be015d03148be8edfdfa8c56e77f9 diff --git a/helix-syntax/languages/tree-sitter-java b/helix-syntax/languages/tree-sitter-java deleted file mode 160000 index bd6186c24..000000000 --- a/helix-syntax/languages/tree-sitter-java +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bd6186c24d5eb13b4623efac9d944dcc095c0dad diff --git a/helix-syntax/languages/tree-sitter-javascript b/helix-syntax/languages/tree-sitter-javascript deleted file mode 160000 index 4a95461c4..000000000 --- a/helix-syntax/languages/tree-sitter-javascript +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 4a95461c4761c624f2263725aca79eeaefd36cad diff --git a/helix-syntax/languages/tree-sitter-json b/helix-syntax/languages/tree-sitter-json deleted file mode 160000 index 65bceef69..000000000 --- a/helix-syntax/languages/tree-sitter-json +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 65bceef69c3b0f24c0b19ce67d79f57c96e90fcb diff --git a/helix-syntax/languages/tree-sitter-julia b/helix-syntax/languages/tree-sitter-julia deleted file mode 160000 index 12ea59726..000000000 --- a/helix-syntax/languages/tree-sitter-julia +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 12ea597262125fc22fd2e91aa953ac69b19c26ca diff --git a/helix-syntax/languages/tree-sitter-latex b/helix-syntax/languages/tree-sitter-latex deleted file mode 160000 index 7f720661d..000000000 --- a/helix-syntax/languages/tree-sitter-latex +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 7f720661de5316c0f8fee956526d4002fa1086d8 diff --git a/helix-syntax/languages/tree-sitter-lean b/helix-syntax/languages/tree-sitter-lean deleted file mode 160000 index d98426109..000000000 --- a/helix-syntax/languages/tree-sitter-lean +++ /dev/null @@ -1 +0,0 @@ -Subproject commit d98426109258b266e1e92358c5f11716d2e8f638 diff --git a/helix-syntax/languages/tree-sitter-ledger b/helix-syntax/languages/tree-sitter-ledger deleted file mode 160000 index 0cdeb0e51..000000000 --- a/helix-syntax/languages/tree-sitter-ledger +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 0cdeb0e51411a3ba5493662952c3039de08939ca diff --git a/helix-syntax/languages/tree-sitter-llvm b/helix-syntax/languages/tree-sitter-llvm deleted file mode 160000 index 3b213925b..000000000 --- a/helix-syntax/languages/tree-sitter-llvm +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 3b213925b9c4f42c1acfe2e10bfbb438d9c6834d diff --git a/helix-syntax/languages/tree-sitter-llvm-mir b/helix-syntax/languages/tree-sitter-llvm-mir deleted file mode 160000 index 06fabca19..000000000 --- a/helix-syntax/languages/tree-sitter-llvm-mir +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 06fabca19454b2dc00c1b211a7cb7ad0bc2585f1 diff --git a/helix-syntax/languages/tree-sitter-lua b/helix-syntax/languages/tree-sitter-lua deleted file mode 160000 index 6f5d40190..000000000 --- a/helix-syntax/languages/tree-sitter-lua +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 6f5d40190ec8a0aa8c8410699353d820f4f7d7a6 diff --git a/helix-syntax/languages/tree-sitter-make b/helix-syntax/languages/tree-sitter-make deleted file mode 160000 index a4b918741..000000000 --- a/helix-syntax/languages/tree-sitter-make +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a4b9187417d6be349ee5fd4b6e77b4172c6827dd diff --git a/helix-syntax/languages/tree-sitter-markdown b/helix-syntax/languages/tree-sitter-markdown deleted file mode 160000 index ad8c32917..000000000 --- a/helix-syntax/languages/tree-sitter-markdown +++ /dev/null @@ -1 +0,0 @@ -Subproject commit ad8c32917a16dfbb387d1da567bf0c3fb6fffde2 diff --git a/helix-syntax/languages/tree-sitter-nix b/helix-syntax/languages/tree-sitter-nix deleted file mode 160000 index 50f38ceab..000000000 --- a/helix-syntax/languages/tree-sitter-nix +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 50f38ceab667f9d482640edfee803d74f4edeba5 diff --git a/helix-syntax/languages/tree-sitter-ocaml b/helix-syntax/languages/tree-sitter-ocaml deleted file mode 160000 index 23d419ba4..000000000 --- a/helix-syntax/languages/tree-sitter-ocaml +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 23d419ba45789c5a47d31448061557716b02750a diff --git a/helix-syntax/languages/tree-sitter-perl b/helix-syntax/languages/tree-sitter-perl deleted file mode 160000 index 0ac2c6da5..000000000 --- a/helix-syntax/languages/tree-sitter-perl +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 0ac2c6da562c7a2c26ed7e8691d4a590f7e8b90a diff --git a/helix-syntax/languages/tree-sitter-php b/helix-syntax/languages/tree-sitter-php deleted file mode 160000 index 57f855461..000000000 --- a/helix-syntax/languages/tree-sitter-php +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 57f855461aeeca73bd4218754fb26b5ac143f98f diff --git a/helix-syntax/languages/tree-sitter-protobuf b/helix-syntax/languages/tree-sitter-protobuf deleted file mode 160000 index 19c211a01..000000000 --- a/helix-syntax/languages/tree-sitter-protobuf +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 19c211a01434d9f03efff99f85e19f967591b175 diff --git a/helix-syntax/languages/tree-sitter-python b/helix-syntax/languages/tree-sitter-python deleted file mode 160000 index d6210ceab..000000000 --- a/helix-syntax/languages/tree-sitter-python +++ /dev/null @@ -1 +0,0 @@ -Subproject commit d6210ceab11e8d812d4ab59c07c81458ec6e5184 diff --git a/helix-syntax/languages/tree-sitter-regex b/helix-syntax/languages/tree-sitter-regex deleted file mode 160000 index e1cfca3c7..000000000 --- a/helix-syntax/languages/tree-sitter-regex +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e1cfca3c79896ff79842f057ea13e529b66af636 diff --git a/helix-syntax/languages/tree-sitter-rescript b/helix-syntax/languages/tree-sitter-rescript deleted file mode 160000 index 761eb9126..000000000 --- a/helix-syntax/languages/tree-sitter-rescript +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 761eb9126b65e078b1b5770ac296b4af8870f933 diff --git a/helix-syntax/languages/tree-sitter-ruby b/helix-syntax/languages/tree-sitter-ruby deleted file mode 160000 index dfff673b4..000000000 --- a/helix-syntax/languages/tree-sitter-ruby +++ /dev/null @@ -1 +0,0 @@ -Subproject commit dfff673b41df7fadcbb609c6338f38da3cdd018e diff --git a/helix-syntax/languages/tree-sitter-rust b/helix-syntax/languages/tree-sitter-rust deleted file mode 160000 index a360da0a2..000000000 --- a/helix-syntax/languages/tree-sitter-rust +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a360da0a29a19c281d08295a35ecd0544d2da211 diff --git a/helix-syntax/languages/tree-sitter-scala b/helix-syntax/languages/tree-sitter-scala deleted file mode 160000 index 0a3dd53a7..000000000 --- a/helix-syntax/languages/tree-sitter-scala +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 0a3dd53a7fc4b352a538397d054380aaa28be54c diff --git a/helix-syntax/languages/tree-sitter-svelte b/helix-syntax/languages/tree-sitter-svelte deleted file mode 160000 index 349a59845..000000000 --- a/helix-syntax/languages/tree-sitter-svelte +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 349a5984513b4a4a9e143a6e746120c6ff6cf6ed diff --git a/helix-syntax/languages/tree-sitter-swift b/helix-syntax/languages/tree-sitter-swift deleted file mode 160000 index a22fa5e19..000000000 --- a/helix-syntax/languages/tree-sitter-swift +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a22fa5e19bae50098e2252ea96cba3aba43f4c58 diff --git a/helix-syntax/languages/tree-sitter-tablegen b/helix-syntax/languages/tree-sitter-tablegen deleted file mode 160000 index 568dd8a93..000000000 --- a/helix-syntax/languages/tree-sitter-tablegen +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 568dd8a937347175fd58db83d4c4cdaeb6069bd2 diff --git a/helix-syntax/languages/tree-sitter-toml b/helix-syntax/languages/tree-sitter-toml deleted file mode 160000 index 7cff70bbc..000000000 --- a/helix-syntax/languages/tree-sitter-toml +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 7cff70bbcbbc62001b465603ca1ea88edd668704 diff --git a/helix-syntax/languages/tree-sitter-tsq b/helix-syntax/languages/tree-sitter-tsq deleted file mode 160000 index b665659d3..000000000 --- a/helix-syntax/languages/tree-sitter-tsq +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b665659d3238e6036e22ed0e24935e60efb39415 diff --git a/helix-syntax/languages/tree-sitter-twig b/helix-syntax/languages/tree-sitter-twig deleted file mode 160000 index b7444181f..000000000 --- a/helix-syntax/languages/tree-sitter-twig +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b7444181fb38e603e25ea8fcdac55f9492e49c27 diff --git a/helix-syntax/languages/tree-sitter-typescript b/helix-syntax/languages/tree-sitter-typescript deleted file mode 160000 index 3e897ea59..000000000 --- a/helix-syntax/languages/tree-sitter-typescript +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 3e897ea5925f037cfae2e551f8e6b12eec2a201a diff --git a/helix-syntax/languages/tree-sitter-vue b/helix-syntax/languages/tree-sitter-vue deleted file mode 160000 index 91fe27547..000000000 --- a/helix-syntax/languages/tree-sitter-vue +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 91fe2754796cd8fba5f229505a23fa08f3546c06 diff --git a/helix-syntax/languages/tree-sitter-wgsl b/helix-syntax/languages/tree-sitter-wgsl deleted file mode 160000 index f00ff5225..000000000 --- a/helix-syntax/languages/tree-sitter-wgsl +++ /dev/null @@ -1 +0,0 @@ -Subproject commit f00ff52251edbd58f4d39c9c3204383253032c11 diff --git a/helix-syntax/languages/tree-sitter-yaml b/helix-syntax/languages/tree-sitter-yaml deleted file mode 160000 index 0e36bed17..000000000 --- a/helix-syntax/languages/tree-sitter-yaml +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 0e36bed171768908f331ff7dff9d956bae016efb diff --git a/helix-syntax/languages/tree-sitter-zig b/helix-syntax/languages/tree-sitter-zig deleted file mode 160000 index 93331b8bd..000000000 --- a/helix-syntax/languages/tree-sitter-zig +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 93331b8bd8b4ebee2b575490b2758f16ad4e9f30 diff --git a/helix-syntax/src/lib.rs b/helix-syntax/src/lib.rs deleted file mode 100644 index b0ec48d82..000000000 --- a/helix-syntax/src/lib.rs +++ /dev/null @@ -1,31 +0,0 @@ -use anyhow::{Context, Result}; -use libloading::{Library, Symbol}; -use tree_sitter::Language; - -fn replace_dashes_with_underscores(name: &str) -> String { - name.replace('-', "_") -} -#[cfg(unix)] -const DYLIB_EXTENSION: &str = "so"; - -#[cfg(windows)] -const DYLIB_EXTENSION: &str = "dll"; - -pub fn get_language(runtime_path: &std::path::Path, name: &str) -> Result { - let name = name.to_ascii_lowercase(); - let mut library_path = runtime_path.join("grammars").join(&name); - // TODO: duplicated under build - library_path.set_extension(DYLIB_EXTENSION); - - let library = unsafe { Library::new(&library_path) } - .with_context(|| format!("Error opening dynamic library {:?}", &library_path))?; - let language_fn_name = format!("tree_sitter_{}", replace_dashes_with_underscores(&name)); - let language = unsafe { - let language_fn: Symbol Language> = library - .get(language_fn_name.as_bytes()) - .with_context(|| format!("Failed to load symbol {}", language_fn_name))?; - language_fn() - }; - std::mem::forget(library); - Ok(language) -} diff --git a/helix-term/Cargo.toml b/helix-term/Cargo.toml index e62496f29..2e0b774ba 100644 --- a/helix-term/Cargo.toml +++ b/helix-term/Cargo.toml @@ -16,6 +16,7 @@ build = true app = true [features] +unicode-lines = ["helix-core/unicode-lines"] [[bin]] name = "hx" @@ -26,9 +27,12 @@ helix-core = { version = "0.6", path = "../helix-core" } helix-view = { version = "0.6", path = "../helix-view" } helix-lsp = { version = "0.6", path = "../helix-lsp" } helix-dap = { version = "0.6", path = "../helix-dap" } +helix-loader = { version = "0.6", path = "../helix-loader" } anyhow = "1" -once_cell = "1.9" +once_cell = "1.10" + +which = "4.2" tokio = { version = "1", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot"] } num_cpus = "1" @@ -37,6 +41,7 @@ crossterm = { version = "0.23", features = ["event-stream"] } signal-hook = "0.3" tokio-stream = "0.1" futures-util = { version = "0.3", features = ["std", "async-await"], default-features = false } +arc-swap = { version = "1.5.0" } # Logging fern = "0.6" @@ -61,5 +66,11 @@ serde = { version = "1.0", features = ["derive"] } grep-regex = "0.1.9" grep-searcher = "0.1.8" +# Remove once retain_mut lands in stable rust +retain_mut = "0.1.7" + [target.'cfg(not(windows))'.dependencies] # https://github.com/vorner/signal-hook/issues/100 signal-hook-tokio = { version = "0.3", features = ["futures-v0_3"] } + +[build-dependencies] +helix-loader = { version = "0.6", path = "../helix-loader" } diff --git a/helix-term/build.rs b/helix-term/build.rs index 21dd5612d..974f4b5ed 100644 --- a/helix-term/build.rs +++ b/helix-term/build.rs @@ -1,17 +1,29 @@ +use helix_loader::grammar::{build_grammars, fetch_grammars}; use std::borrow::Cow; use std::process::Command; +const VERSION: &str = include_str!("../VERSION"); + fn main() { let git_hash = Command::new("git") .args(&["rev-parse", "HEAD"]) .output() .ok() + .filter(|output| output.status.success()) .and_then(|x| String::from_utf8(x.stdout).ok()); let version: Cow<_> = match git_hash { - Some(git_hash) => format!("{} ({})", env!("CARGO_PKG_VERSION"), &git_hash[..8]).into(), - None => env!("CARGO_PKG_VERSION").into(), + Some(git_hash) => format!("{} ({})", VERSION, &git_hash[..8]).into(), + None => VERSION.into(), }; + if std::env::var("HELIX_DISABLE_AUTO_GRAMMAR_BUILD").is_err() { + fetch_grammars().expect("Failed to fetch tree-sitter grammars"); + build_grammars().expect("Failed to compile tree-sitter grammars"); + } + + println!("cargo:rerun-if-changed=../runtime/grammars/"); + println!("cargo:rerun-if-changed=../VERSION"); + println!("cargo:rustc-env=VERSION_AND_GIT_HASH={}", version); } diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index 49eb08d0d..09b1ff619 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -1,18 +1,19 @@ +use arc_swap::{access::Map, ArcSwap}; use helix_core::{ config::{default_syntax_loader, user_syntax_loader}, pos_at_coords, syntax, Selection, }; -use helix_dap::{self as dap, Payload, Request}; use helix_lsp::{lsp, util::lsp_pos_to_pos, LspProgressMap}; -use helix_view::{editor::Breakpoint, theme, Editor}; +use helix_view::{align_view, editor::ConfigEvent, theme, Align, Editor}; use serde_json::json; use crate::{ args::Args, - commands::{align_view, apply_workspace_edit, fetch_stack_trace, Align}, + commands::apply_workspace_edit, compositor::Compositor, config::Config, job::Jobs, + keymap::Keymaps, ui::{self, overlay::overlayed}, }; @@ -42,16 +43,10 @@ pub struct Application { compositor: Compositor, editor: Editor, - // TODO should be separate to take only part of the config - config: Config, + config: Arc>, - // Currently never read from. Remove the `allow(dead_code)` when - // that changes. #[allow(dead_code)] theme_loader: Arc, - - // Currently never read from. Remove the `allow(dead_code)` when - // that changes. #[allow(dead_code)] syn_loader: Arc, @@ -61,17 +56,16 @@ pub struct Application { } impl Application { - pub fn new(args: Args, mut config: Config) -> Result { + pub fn new(args: Args, config: Config) -> Result { use helix_view::editor::Action; let mut compositor = Compositor::new()?; let size = compositor.size(); - let conf_dir = helix_core::config_dir(); + let conf_dir = helix_loader::config_dir(); let theme_loader = - std::sync::Arc::new(theme::Loader::new(&conf_dir, &helix_core::runtime_dir())); + std::sync::Arc::new(theme::Loader::new(&conf_dir, &helix_loader::runtime_dir())); - // load default and user config, and merge both let true_color = config.editor.true_color || crate::true_color(); let theme = config .theme @@ -104,18 +98,24 @@ impl Application { }); let syn_loader = std::sync::Arc::new(syntax::Loader::new(syn_loader_conf)); + let config = Arc::new(ArcSwap::from_pointee(config)); let mut editor = Editor::new( size, theme_loader.clone(), syn_loader.clone(), - config.editor.clone(), + Box::new(Map::new(Arc::clone(&config), |config: &Config| { + &config.editor + })), ); - let editor_view = Box::new(ui::EditorView::new(std::mem::take(&mut config.keys))); + let keys = Box::new(Map::new(Arc::clone(&config), |config: &Config| { + &config.keys + })); + let editor_view = Box::new(ui::EditorView::new(Keymaps::new(keys))); compositor.push(editor_view); if args.load_tutor { - let path = helix_core::runtime_dir().join("tutor.txt"); + let path = helix_loader::runtime_dir().join("tutor.txt"); editor.open(path, Action::VerticalSplit)?; // Unset path to prevent accidentally saving to the original tutor file. doc_mut!(editor).set_path(None)?; @@ -124,7 +124,7 @@ impl Application { if first.is_dir() { std::env::set_current_dir(&first)?; editor.new_file(Action::VerticalSplit); - let picker = ui::file_picker(".".into(), &config.editor); + let picker = ui::file_picker(".".into(), &config.load().editor); compositor.push(Box::new(overlayed(picker))); } else { let nr_of_files = args.files.len(); @@ -187,17 +187,13 @@ impl Application { } fn render(&mut self) { - let editor = &mut self.editor; - let compositor = &mut self.compositor; - let jobs = &mut self.jobs; - let mut cx = crate::compositor::Context { - editor, - jobs, + editor: &mut self.editor, + jobs: &mut self.jobs, scroll: None, }; - compositor.render(&mut cx); + self.compositor.render(&mut cx); } pub async fn event_loop(&mut self) { @@ -233,7 +229,14 @@ impl Application { } } Some(payload) = self.editor.debugger_events.next() => { - self.handle_debugger_message(payload).await; + let needs_render = self.editor.handle_debugger_message(payload).await; + if needs_render { + self.render(); + } + } + Some(config_event) = self.editor.config_events.1.recv() => { + self.handle_config_events(config_event); + self.render(); } Some(callback) = self.jobs.futures.next() => { self.jobs.handle_callback(&mut self.editor, &mut self.compositor, callback); @@ -252,6 +255,55 @@ impl Application { } } + pub fn handle_config_events(&mut self, config_event: ConfigEvent) { + match config_event { + ConfigEvent::Refresh => self.refresh_config(), + + // Since only the Application can make changes to Editor's config, + // the Editor must send up a new copy of a modified config so that + // the Application can apply it. + ConfigEvent::Update(editor_config) => { + let mut app_config = (*self.config.load().clone()).clone(); + app_config.editor = editor_config; + self.config.store(Arc::new(app_config)); + } + } + } + + fn refresh_config(&mut self) { + let config = Config::load(helix_loader::config_file()).unwrap_or_else(|err| { + self.editor.set_error(err.to_string()); + Config::default() + }); + + // Refresh theme + if let Some(theme) = config.theme.clone() { + let true_color = self.true_color(); + self.editor.set_theme( + self.theme_loader + .load(&theme) + .map_err(|e| { + log::warn!("failed to load theme `{}` - {}", theme, e); + e + }) + .ok() + .filter(|theme| (true_color || theme.is_16_color())) + .unwrap_or_else(|| { + if true_color { + self.theme_loader.default() + } else { + self.theme_loader.base16_default() + } + }), + ); + } + self.config.store(Arc::new(config)); + } + + fn true_color(&self) -> bool { + self.config.load().editor.true_color || crate::true_color() + } + #[cfg(windows)] // no signal handling available on windows pub async fn handle_signals(&mut self, _signal: ()) {} @@ -278,31 +330,20 @@ impl Application { } pub fn handle_idle_timeout(&mut self) { - use crate::commands::{insert::idle_completion, Context}; - use helix_view::document::Mode; - - if doc!(self.editor).mode != Mode::Insert || !self.config.editor.auto_completion { - return; - } + use crate::compositor::EventResult; let editor_view = self .compositor .find::() .expect("expected at least one EditorView"); - if editor_view.completion.is_some() { - return; - } - - let mut cx = Context { - register: None, + let mut cx = crate::compositor::Context { editor: &mut self.editor, jobs: &mut self.jobs, - count: None, - callback: None, - on_next_key_callback: None, + scroll: None, }; - idle_completion(&mut cx); - self.render(); + if let EventResult::Consumed(_) = editor_view.handle_idle_timeout(&mut cx) { + self.render(); + } } pub fn handle_terminal_events(&mut self, event: Option>) { @@ -329,185 +370,6 @@ impl Application { } } - pub async fn handle_debugger_message(&mut self, payload: helix_dap::Payload) { - use crate::commands::dap::{breakpoints_changed, select_thread_id}; - use dap::requests::RunInTerminal; - use helix_dap::{events, Event}; - - let debugger = match self.editor.debugger.as_mut() { - Some(debugger) => debugger, - None => return, - }; - match payload { - Payload::Event(ev) => match *ev { - Event::Stopped(events::Stopped { - thread_id, - description, - text, - reason, - all_threads_stopped, - .. - }) => { - let all_threads_stopped = all_threads_stopped.unwrap_or_default(); - - if all_threads_stopped { - if let Ok(response) = debugger.request::(()).await { - for thread in response.threads { - fetch_stack_trace(debugger, thread.id).await; - } - select_thread_id( - &mut self.editor, - thread_id.unwrap_or_default(), - false, - ) - .await; - } - } else if let Some(thread_id) = thread_id { - debugger.thread_states.insert(thread_id, reason.clone()); // TODO: dap uses "type" || "reason" here - - // whichever thread stops is made "current" (if no previously selected thread). - select_thread_id(&mut self.editor, thread_id, false).await; - } - - let scope = match thread_id { - Some(id) => format!("Thread {}", id), - None => "Target".to_owned(), - }; - - let mut status = format!("{} stopped because of {}", scope, reason); - if let Some(desc) = description { - status.push_str(&format!(" {}", desc)); - } - if let Some(text) = text { - status.push_str(&format!(" {}", text)); - } - if all_threads_stopped { - status.push_str(" (all threads stopped)"); - } - - self.editor.set_status(status); - } - Event::Continued(events::Continued { thread_id, .. }) => { - debugger - .thread_states - .insert(thread_id, "running".to_owned()); - if debugger.thread_id == Some(thread_id) { - debugger.resume_application(); - } - } - Event::Thread(_) => { - // TODO: update thread_states, make threads request - } - Event::Breakpoint(events::Breakpoint { reason, breakpoint }) => { - match &reason[..] { - "new" => { - if let Some(source) = breakpoint.source { - self.editor - .breakpoints - .entry(source.path.unwrap()) // TODO: no unwraps - .or_default() - .push(Breakpoint { - id: breakpoint.id, - verified: breakpoint.verified, - message: breakpoint.message, - line: breakpoint.line.unwrap().saturating_sub(1), // TODO: no unwrap - column: breakpoint.column, - ..Default::default() - }); - } - } - "changed" => { - for breakpoints in self.editor.breakpoints.values_mut() { - if let Some(i) = - breakpoints.iter().position(|b| b.id == breakpoint.id) - { - breakpoints[i].verified = breakpoint.verified; - breakpoints[i].message = breakpoint.message.clone(); - breakpoints[i].line = - breakpoint.line.unwrap().saturating_sub(1); // TODO: no unwrap - breakpoints[i].column = breakpoint.column; - } - } - } - "removed" => { - for breakpoints in self.editor.breakpoints.values_mut() { - if let Some(i) = - breakpoints.iter().position(|b| b.id == breakpoint.id) - { - breakpoints.remove(i); - } - } - } - reason => { - warn!("Unknown breakpoint event: {}", reason); - } - } - } - Event::Output(events::Output { - category, output, .. - }) => { - let prefix = match category { - Some(category) => { - if &category == "telemetry" { - return; - } - format!("Debug ({}):", category) - } - None => "Debug:".to_owned(), - }; - - log::info!("{}", output); - self.editor.set_status(format!("{} {}", prefix, output)); - } - Event::Initialized => { - // send existing breakpoints - for (path, breakpoints) in &mut self.editor.breakpoints { - // TODO: call futures in parallel, await all - let _ = breakpoints_changed(debugger, path.clone(), breakpoints); - } - // TODO: fetch breakpoints (in case we're attaching) - - if debugger.configuration_done().await.is_ok() { - self.editor.set_status("Debugged application started"); - }; // TODO: do we need to handle error? - } - ev => { - log::warn!("Unhandled event {:?}", ev); - return; // return early to skip render - } - }, - Payload::Response(_) => unreachable!(), - Payload::Request(request) => match request.command.as_str() { - RunInTerminal::COMMAND => { - let arguments: dap::requests::RunInTerminalArguments = - serde_json::from_value(request.arguments.unwrap_or_default()).unwrap(); - // TODO: no unwrap - - // TODO: handle cwd - let process = std::process::Command::new("tmux") - .arg("split-window") - .arg(arguments.args.join(" ")) // TODO: first arg is wrong, it uses current dir - .spawn() - .unwrap(); - - let _ = debugger - .reply( - request.seq, - dap::requests::RunInTerminal::COMMAND, - serde_json::to_value(dap::requests::RunInTerminalResponse { - process_id: Some(process.id()), - shell_process_id: None, - }) - .map_err(|e| e.into()), - ) - .await; - } - _ => log::error!("DAP reverse request not implemented: {:?}", request), - }, - } - self.render(); - } - pub async fn handle_language_server_message( &mut self, call: helix_lsp::Call, @@ -533,6 +395,13 @@ impl Application { } }; + // Trigger a workspace/didChangeConfiguration notification after initialization. + // This might not be required by the spec but Neovim does this as well, so it's + // probably a good idea for compatibility. + if let Some(config) = language_server.config() { + tokio::spawn(language_server.did_change_configuration(config.clone())); + } + let docs = self.editor.documents().filter(|doc| { doc.language_server().map(|server| server.id()) == Some(server_id) }); @@ -712,7 +581,7 @@ impl Application { self.lsp_progress.update(server_id, token, work); } - if self.config.lsp.display_messages { + if self.config.load().editor.lsp.display_messages { self.editor.set_status(status); } } @@ -728,15 +597,6 @@ impl Application { Some(call) => call, None => { error!("Method not found {}", method); - // language_server.reply( - // call.id, - // // TODO: make a Into trait that can cast to Err(jsonrpc::Error) - // Err(helix_lsp::jsonrpc::Error { - // code: helix_lsp::jsonrpc::ErrorCode::MethodNotFound, - // message: "Method not found".to_string(), - // data: None, - // }), - // ); return; } }; @@ -789,9 +649,40 @@ impl Application { })), )); } + MethodCall::WorkspaceConfiguration(params) => { + let language_server = + match self.editor.language_servers.get_by_id(server_id) { + Some(language_server) => language_server, + None => { + warn!("can't find language server with id `{}`", server_id); + return; + } + }; + let result: Vec<_> = params + .items + .iter() + .map(|item| { + let mut config = match &item.scope_uri { + Some(scope) => { + let path = scope.to_file_path().ok()?; + let doc = self.editor.document_by_path(path)?; + doc.language_config()?.config.as_ref()? + } + None => language_server.config()?, + }; + if let Some(section) = item.section.as_ref() { + for part in section.split('.') { + config = config.get(part)?; + } + } + Some(config) + }) + .collect(); + tokio::spawn(language_server.reply(id, Ok(json!(result)))); + } } } - e => unreachable!("{:?}", e), + Call::Invalid { id } => log::error!("LSP invalid method call id={:?}", id), } } @@ -799,7 +690,7 @@ impl Application { terminal::enable_raw_mode()?; let mut stdout = stdout(); execute!(stdout, terminal::EnterAlternateScreen)?; - if self.config.editor.mouse { + if self.config.load().editor.mouse { execute!(stdout, EnableMouseCapture)?; } Ok(()) diff --git a/helix-term/src/args.rs b/helix-term/src/args.rs index 247d5b320..b99c7d1a2 100644 --- a/helix-term/src/args.rs +++ b/helix-term/src/args.rs @@ -1,4 +1,4 @@ -use anyhow::{Error, Result}; +use anyhow::Result; use helix_core::Position; use std::path::{Path, PathBuf}; @@ -6,7 +6,11 @@ use std::path::{Path, PathBuf}; pub struct Args { pub display_help: bool, pub display_version: bool, + pub health: bool, + pub health_arg: Option, pub load_tutor: bool, + pub fetch_grammars: bool, + pub build_grammars: bool, pub verbosity: u64, pub files: Vec<(PathBuf, Position)>, } @@ -14,22 +18,29 @@ pub struct Args { impl Args { pub fn parse_args() -> Result { let mut args = Args::default(); - let argv: Vec = std::env::args().collect(); - let mut iter = argv.iter(); + let mut argv = std::env::args().peekable(); - iter.next(); // skip the program, we don't care about that + argv.next(); // skip the program, we don't care about that - for arg in &mut iter { + while let Some(arg) = argv.next() { match arg.as_str() { "--" => break, // stop parsing at this point treat the remaining as files "--version" => args.display_version = true, "--help" => args.display_help = true, "--tutor" => args.load_tutor = true, + "--health" => { + args.health = true; + args.health_arg = argv.next_if(|opt| !opt.starts_with('-')); + } + "-g" | "--grammar" => match argv.next().as_deref() { + Some("fetch") => args.fetch_grammars = true, + Some("build") => args.build_grammars = true, + _ => { + anyhow::bail!("--grammar must be followed by either 'fetch' or 'build'") + } + }, arg if arg.starts_with("--") => { - return Err(Error::msg(format!( - "unexpected double dash argument: {}", - arg - ))) + anyhow::bail!("unexpected double dash argument: {}", arg) } arg if arg.starts_with('-') => { let arg = arg.get(1..).unwrap().chars(); @@ -38,7 +49,7 @@ impl Args { 'v' => args.verbosity += 1, 'V' => args.display_version = true, 'h' => args.display_help = true, - _ => return Err(Error::msg(format!("unexpected short arg {}", chr))), + _ => anyhow::bail!("unexpected short arg {}", chr), } } } @@ -47,8 +58,8 @@ impl Args { } // push the remaining args, if any to the files - for arg in iter { - args.files.push(parse_file(arg)); + for arg in argv { + args.files.push(parse_file(&arg)); } Ok(args) @@ -82,7 +93,7 @@ fn split_path_row_col(s: &str) -> Option<(PathBuf, Position)> { /// /// Does not validate if file.rs is a file or directory. fn split_path_row(s: &str) -> Option<(PathBuf, Position)> { - let (row, path) = s.rsplit_once(':')?; + let (path, row) = s.rsplit_once(':')?; let row: usize = row.parse().ok()?; let path = path.into(); let pos = Position::new(row.saturating_sub(1), 0); diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index ecf954b26..bd66f26a2 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -1,6 +1,10 @@ pub(crate) mod dap; +pub(crate) mod lsp; +pub(crate) mod typed; pub use dap::*; +pub use lsp::*; +pub use typed::*; use helix_core::{ comment, coords_at_pos, find_first_non_whitespace_char, find_root, graphemes, @@ -33,18 +37,13 @@ use helix_view::{ use anyhow::{anyhow, bail, ensure, Context as _}; use fuzzy_matcher::FuzzyMatcher; -use helix_lsp::{ - block_on, lsp, - util::{lsp_pos_to_pos, lsp_range_to_range, pos_to_lsp_pos, range_to_lsp_range}, - OffsetEncoding, -}; use insert::*; use movement::Movement; use crate::{ args, compositor::{self, Component, Compositor}, - ui::{self, overlay::overlayed, FilePicker, Popup, Prompt, PromptEvent}, + ui::{self, overlay::overlayed, FilePicker, Picker, Popup, Prompt, PromptEvent}, }; use crate::job::{self, Job, Jobs}; @@ -119,29 +118,7 @@ impl<'a> Context<'a> { } } -pub enum Align { - Top, - Center, - Bottom, -} - -pub fn align_view(doc: &Document, view: &mut View, align: Align) { - let pos = doc - .selection(view.id) - .primary() - .cursor(doc.text().slice(..)); - let line = doc.text().char_to_line(pos); - - let height = view.inner_area().height as usize; - - let relative = match align { - Align::Center => height / 2, - Align::Top => 0, - Align::Bottom => height, - }; - - view.offset.row = line.saturating_sub(relative); -} +use helix_view::{align_view, Align}; /// A MappableCommand is either a static command like "jump_view_up" or a Typable command like /// :format. It causes a side-effect on the state (usually by creating and applying a transaction). @@ -182,7 +159,7 @@ impl MappableCommand { match &self { Self::Typable { name, args, doc: _ } => { let args: Vec> = args.iter().map(Cow::from).collect(); - if let Some(command) = cmd::TYPABLE_COMMAND_MAP.get(name.as_str()) { + if let Some(command) = typed::TYPABLE_COMMAND_MAP.get(name.as_str()) { let mut cx = compositor::Context { editor: cx.editor, jobs: cx.jobs, @@ -237,14 +214,14 @@ impl MappableCommand { extend_prev_long_word_start, "Extend to beginning of previous long word", extend_next_long_word_end, "Extend to end of next long word", extend_next_word_end, "Extend to end of next word", - find_till_char, "Move till next occurance of char", - find_next_char, "Move to next occurance of char", - extend_till_char, "Extend till next occurance of char", - extend_next_char, "Extend to next occurance of char", - till_prev_char, "Move till previous occurance of char", - find_prev_char, "Move to previous occurance of char", - extend_till_prev_char, "Extend till previous occurance of char", - extend_prev_char, "Extend to previous occurance of char", + find_till_char, "Move till next occurrence of char", + find_next_char, "Move to next occurrence of char", + extend_till_char, "Extend till next occurrence of char", + extend_next_char, "Extend to next occurrence of char", + till_prev_char, "Move till previous occurrence of char", + find_prev_char, "Move to previous occurrence of char", + extend_till_prev_char, "Extend till previous occurrence of char", + extend_prev_char, "Extend to previous occurrence of char", repeat_last_motion, "repeat last motion(extend_next_char, extend_till_char, find_next_char, find_till_char...)", replace, "Replace with new char", switch_case, "Switch (toggle) case", @@ -381,7 +358,9 @@ impl MappableCommand { jump_view_down, "Jump to the split below", rotate_view, "Goto next window", hsplit, "Horizontal bottom split", + hsplit_new, "Horizontal bottom split scratch buffer", vsplit, "Vertical right split", + vsplit_new, "Vertical right split scratch buffer", wclose, "Close window", wonly, "Current window only", select_register, "Select register", @@ -404,6 +383,8 @@ impl MappableCommand { goto_prev_class, "Goto previous class", goto_next_parameter, "Goto next parameter", goto_prev_parameter, "Goto previous parameter", + goto_next_comment, "Goto next comment", + goto_prev_comment, "Goto previous comment", dap_launch, "Launch debug target", dap_toggle_breakpoint, "Toggle breakpoint", dap_continue, "Continue program execution", @@ -430,6 +411,7 @@ impl MappableCommand { decrement, "Decrement", record_macro, "Record macro", replay_macro, "Replay macro", + command_palette, "Open command pallete", ); } @@ -459,7 +441,7 @@ impl std::str::FromStr for MappableCommand { let args = typable_command .map(|s| s.to_owned()) .collect::>(); - cmd::TYPABLE_COMMAND_MAP + typed::TYPABLE_COMMAND_MAP .get(name) .map(|cmd| MappableCommand::Typable { name: cmd.name.to_owned(), @@ -774,74 +756,71 @@ fn trim_selections(cx: &mut Context) { // align text in selection fn align_selections(cx: &mut Context) { - let align_style = cx.count(); - if align_style > 3 { - cx.editor - .set_error("align only accept 1,2,3 as count to set left/center/right align"); - return; - } - let (view, doc) = current!(cx.editor); let text = doc.text().slice(..); let selection = doc.selection(view.id); - let mut column_widths = vec![]; - let mut last_line = text.len_lines(); - let mut column = 0; - // first of all, we need compute all column's width, let use max width of the selections in a column - for sel in selection { - let (l1, l2) = sel.line_range(text); - if l1 != l2 { + + let mut column_widths: Vec> = Vec::new(); + let mut last_line = text.len_lines() + 1; + let mut col = 0; + + for range in selection { + let coords = coords_at_pos(text, range.head); + let anchor_coords = coords_at_pos(text, range.anchor); + + if coords.row != anchor_coords.row { cx.editor .set_error("align cannot work with multi line selections"); return; } - // if the selection is not in the same line with last selection, we set the column to 0 - column = if l1 != last_line { 0 } else { column + 1 }; - last_line = l1; - if column < column_widths.len() { - if sel.to() - sel.from() > column_widths[column] { - column_widths[column] = sel.to() - sel.from(); - } - } else { - // a new column, current selection width is the temp width of the column - column_widths.push(sel.to() - sel.from()); + col = if coords.row == last_line { col + 1 } else { 0 }; + + if col >= column_widths.len() { + column_widths.push(Vec::new()); } + column_widths[col].push((range.from(), coords.col)); + + last_line = coords.row; } - last_line = text.len_lines(); - // once we get the with of each column, we transform each selection with to it's column width based on the align style - let transaction = Transaction::change_by_selection(doc.text(), selection, |range| { - let l = range.cursor_line(text); - column = if l != last_line { 0 } else { column + 1 }; - last_line = l; - ( - range.from(), - range.to(), - Some( - align_fragment_to_width(&range.fragment(text), column_widths[column], align_style) - .into(), - ), - ) - }); + let mut changes = Vec::with_capacity(selection.len()); - doc.apply(&transaction, view.id); -} + // Account for changes on each row + let len = column_widths.first().map(|cols| cols.len()).unwrap_or(0); + let mut offs = vec![0; len]; + + for col in column_widths { + let max_col = col + .iter() + .enumerate() + .map(|(row, (_, cursor))| *cursor + offs[row]) + .max() + .unwrap_or(0); -fn align_fragment_to_width(fragment: &str, width: usize, align_style: usize) -> String { - let trimed = fragment.trim_matches(|c| c == ' '); - let mut s = " ".repeat(width - trimed.chars().count()); - match align_style { - 1 => s.insert_str(0, trimed), // left align - 2 => s.insert_str(s.len() / 2, trimed), // center align - 3 => s.push_str(trimed), // right align - n => unimplemented!("{}", n), + for (row, (insert_pos, last_col)) in col.into_iter().enumerate() { + let ins_count = max_col - (last_col + offs[row]); + + if ins_count == 0 { + continue; + } + + offs[row] += ins_count; + + changes.push((insert_pos, insert_pos, Some(" ".repeat(ins_count).into()))); + } } - s + + // The changeset has to be sorted + changes.sort_unstable_by_key(|(from, _, _)| *from); + + let transaction = Transaction::change(doc.text(), changes.into_iter()); + doc.apply(&transaction, view.id); } fn goto_window(cx: &mut Context, align: Align) { let count = cx.count() - 1; + let config = cx.editor.config(); let (view, doc) = current!(cx.editor); let height = view.inner_area().height as usize; @@ -850,7 +829,7 @@ fn goto_window(cx: &mut Context, align: Align) { // - 1 so we have at least one gap in the middle. // a height of 6 with padding of 3 on each side will keep shifting the view back and forth // as we type - let scrolloff = cx.editor.config.scrolloff.min(height.saturating_sub(1) / 2); + let scrolloff = config.scrolloff.min(height.saturating_sub(1) / 2); let last_line = view.last_line(doc); @@ -1274,6 +1253,7 @@ fn switch_to_lowercase(cx: &mut Context) { pub fn scroll(cx: &mut Context, offset: usize, direction: Direction) { use Direction::*; + let config = cx.editor.config(); let (view, doc) = current!(cx.editor); let range = doc.selection(view.id).primary(); @@ -1292,7 +1272,7 @@ pub fn scroll(cx: &mut Context, offset: usize, direction: Direction) { let height = view.inner_area().height; - let scrolloff = cx.editor.config.scrolloff.min(height as usize / 2); + let scrolloff = config.scrolloff.min(height as usize / 2); view.offset.row = match direction { Forward => view.offset.row + offset, @@ -1431,11 +1411,11 @@ fn select_all(cx: &mut Context) { fn select_regex(cx: &mut Context) { let reg = cx.register.unwrap_or('/'); - let prompt = ui::regex_prompt( + ui::regex_prompt( cx, "select:".into(), Some(reg), - |_input: &str| Vec::new(), + ui::completers::none, move |view, doc, regex, event| { if event != PromptEvent::Update { return; @@ -1448,17 +1428,15 @@ fn select_regex(cx: &mut Context) { } }, ); - - cx.push_layer(Box::new(prompt)); } fn split_selection(cx: &mut Context) { let reg = cx.register.unwrap_or('/'); - let prompt = ui::regex_prompt( + ui::regex_prompt( cx, "split:".into(), Some(reg), - |_input: &str| Vec::new(), + ui::completers::none, move |view, doc, regex, event| { if event != PromptEvent::Update { return; @@ -1468,8 +1446,6 @@ fn split_selection(cx: &mut Context) { doc.set_selection(view.id, selection); }, ); - - cx.push_layer(Box::new(prompt)); } fn split_selection_on_newline(cx: &mut Context) { @@ -1498,7 +1474,7 @@ fn search_impl( let selection = doc.selection(view.id); // Get the right side of the primary block cursor for forward search, or the - //grapheme before the start of the selection for reverse search. + // grapheme before the start of the selection for reverse search. let start = match direction { Direction::Forward => text.char_to_byte(graphemes::next_grapheme_boundary( text, @@ -1510,10 +1486,10 @@ fn search_impl( )), }; - //A regex::Match returns byte-positions in the str. In the case where we - //do a reverse search and wraparound to the end, we don't need to search - //the text before the current cursor position for matches, but by slicing - //it out, we need to add it back to the position of the selection. + // A regex::Match returns byte-positions in the str. In the case where we + // do a reverse search and wraparound to the end, we don't need to search + // the text before the current cursor position for matches, but by slicing + // it out, we need to add it back to the position of the selection. let mut offset = 0; // use find_at to find the next match after the cursor, loop around the end @@ -1557,6 +1533,7 @@ fn search_impl( }; doc.set_selection(view.id, selection); + // TODO: is_cursor_in_view does the same calculation as ensure_cursor_in_view if view.is_cursor_in_view(doc, 0) { view.ensure_cursor_in_view(doc, scrolloff); } else { @@ -1584,8 +1561,9 @@ fn rsearch(cx: &mut Context) { fn searcher(cx: &mut Context, direction: Direction) { let reg = cx.register.unwrap_or('/'); - let scrolloff = cx.editor.config.scrolloff; - let wrap_around = cx.editor.config.search.wrap_around; + let config = cx.editor.config(); + let scrolloff = config.scrolloff; + let wrap_around = config.search.wrap_around; let doc = doc!(cx.editor); @@ -1596,11 +1574,11 @@ fn searcher(cx: &mut Context, direction: Direction) { let contents = doc.text().slice(..).to_string(); let completions = search_completions(cx, Some(reg)); - let prompt = ui::regex_prompt( + ui::regex_prompt( cx, "search:".into(), Some(reg), - move |input: &str| { + move |_editor: &Editor, input: &str| { completions .iter() .filter(|comp| comp.starts_with(input)) @@ -1623,18 +1601,17 @@ fn searcher(cx: &mut Context, direction: Direction) { ); }, ); - - cx.push_layer(Box::new(prompt)); } fn search_next_or_prev_impl(cx: &mut Context, movement: Movement, direction: Direction) { - let scrolloff = cx.editor.config.scrolloff; + let config = cx.editor.config(); + let scrolloff = config.scrolloff; let (view, doc) = current!(cx.editor); let registers = &cx.editor.registers; if let Some(query) = registers.read('/') { let query = query.last().unwrap(); let contents = doc.text().slice(..).to_string(); - let search_config = &cx.editor.config.search; + let search_config = &config.search; let case_insensitive = if search_config.smart_case { !query.chars().any(char::is_uppercase) } else { @@ -1643,6 +1620,7 @@ fn search_next_or_prev_impl(cx: &mut Context, movement: Movement, direction: Dir let wrap_around = search_config.wrap_around; if let Ok(regex) = RegexBuilder::new(query) .case_insensitive(case_insensitive) + .multi_line(true) .build() { search_impl( @@ -1693,15 +1671,16 @@ fn search_selection(cx: &mut Context) { fn global_search(cx: &mut Context) { let (all_matches_sx, all_matches_rx) = tokio::sync::mpsc::unbounded_channel::<(usize, PathBuf)>(); - let smart_case = cx.editor.config.search.smart_case; - let file_picker_config = cx.editor.config.file_picker.clone(); + let config = cx.editor.config(); + let smart_case = config.search.smart_case; + let file_picker_config = config.file_picker.clone(); let completions = search_completions(cx, None); - let prompt = ui::regex_prompt( + ui::regex_prompt( cx, "global-search:".into(), None, - move |input: &str| { + move |_editor: &Editor, input: &str| { completions .iter() .filter(|comp| comp.starts_with(input)) @@ -1733,39 +1712,37 @@ fn global_search(cx: &mut Context) { .max_depth(file_picker_config.max_depth) .build_parallel() .run(|| { - let mut searcher_cl = searcher.clone(); - let matcher_cl = matcher.clone(); - let all_matches_sx_cl = all_matches_sx.clone(); - Box::new(move |dent: Result| -> WalkState { - let dent = match dent { - Ok(dent) => dent, + let mut searcher = searcher.clone(); + let matcher = matcher.clone(); + let all_matches_sx = all_matches_sx.clone(); + Box::new(move |entry: Result| -> WalkState { + let entry = match entry { + Ok(entry) => entry, Err(_) => return WalkState::Continue, }; - match dent.file_type() { - Some(fi) => { - if !fi.is_file() { - return WalkState::Continue; - } - } - None => return WalkState::Continue, - } + match entry.file_type() { + Some(entry) if entry.is_file() => {} + // skip everything else + _ => return WalkState::Continue, + }; - let result_sink = sinks::UTF8(|line_num, _| { - match all_matches_sx_cl - .send((line_num as usize - 1, dent.path().to_path_buf())) - { - Ok(_) => Ok(true), - Err(_) => Ok(false), - } - }); - let result = - searcher_cl.search_path(&matcher_cl, dent.path(), result_sink); + let result = searcher.search_path( + &matcher, + entry.path(), + sinks::UTF8(|line_num, _| { + all_matches_sx + .send((line_num as usize - 1, entry.path().to_path_buf())) + .unwrap(); + + Ok(true) + }), + ); if let Err(err) = result { log::error!( "Global search error: {}, {}", - dent.path().display(), + entry.path().display(), err ); } @@ -1779,8 +1756,6 @@ fn global_search(cx: &mut Context) { }, ); - cx.push_layer(Box::new(prompt)); - let current_path = doc_mut!(cx.editor).path().cloned(); let show_picker = async move { @@ -1841,17 +1816,20 @@ fn extend_line(cx: &mut Context) { let (view, doc) = current!(cx.editor); let text = doc.text(); - let range = doc.selection(view.id).primary(); + let selection = doc.selection(view.id).clone().transform(|range| { + let (start_line, end_line) = range.line_range(text.slice(..)); - let (start_line, end_line) = range.line_range(text.slice(..)); - let start = text.line_to_char(start_line); - let mut end = text.line_to_char((end_line + count).min(text.len_lines())); + let start = text.line_to_char(start_line); + let mut end = text.line_to_char((end_line + count).min(text.len_lines())); - if range.from() == start && range.to() == end { - end = text.line_to_char((end_line + count + 1).min(text.len_lines())); - } + // go to next line if current line is selected + if range.from() == start && range.to() == end { + end = text.line_to_char((end_line + count + 1).min(text.len_lines())); + } + Range::new(start, end) + }); - doc.set_selection(view.id, Selection::single(start, end)); + doc.set_selection(view.id, selection); } fn extend_to_line_bounds(cx: &mut Context) { @@ -2020,2078 +1998,258 @@ fn append_mode(cx: &mut Context) { doc.set_selection(view.id, selection); } -pub mod cmd { - use super::*; - - use helix_view::editor::Action; - use ui::completers::{self, Completer}; - - #[derive(Clone)] - pub struct TypableCommand { - pub name: &'static str, - pub aliases: &'static [&'static str], - pub doc: &'static str, - // params, flags, helper, completer - pub fun: fn(&mut compositor::Context, &[Cow], PromptEvent) -> anyhow::Result<()>, - pub completer: Option, - } - - fn quit( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - // last view and we have unsaved changes - if cx.editor.tree.views().count() == 1 { - buffers_remaining_impl(cx.editor)? - } +fn file_picker(cx: &mut Context) { + // We don't specify language markers, root will be the root of the current git repo + let root = find_root(None, &[]).unwrap_or_else(|| PathBuf::from("./")); + let picker = ui::file_picker(root, &cx.editor.config()); + cx.push_layer(Box::new(overlayed(picker))); +} - cx.editor.close(view!(cx.editor).id); +fn buffer_picker(cx: &mut Context) { + let current = view!(cx.editor).doc; - Ok(()) + struct BufferMeta { + id: DocumentId, + path: Option, + is_modified: bool, + is_current: bool, } - fn force_quit( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - cx.editor.close(view!(cx.editor).id); + impl BufferMeta { + fn format(&self) -> Cow { + let path = self + .path + .as_deref() + .map(helix_core::path::get_relative_path); + let path = match path.as_deref().and_then(Path::to_str) { + Some(path) => path, + None => SCRATCH_BUFFER_NAME, + }; - Ok(()) - } + let mut flags = Vec::new(); + if self.is_modified { + flags.push("+"); + } + if self.is_current { + flags.push("*"); + } - fn open( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - ensure!(!args.is_empty(), "wrong argument count"); - for arg in args { - let (path, pos) = args::parse_file(arg); - let _ = cx.editor.open(path, Action::Replace)?; - let (view, doc) = current!(cx.editor); - let pos = Selection::point(pos_at_coords(doc.text().slice(..), pos, true)); - doc.set_selection(view.id, pos); - // does not affect opening a buffer without pos - align_view(doc, view, Align::Center); + let flag = if flags.is_empty() { + "".into() + } else { + format!(" ({})", flags.join("")) + }; + Cow::Owned(format!("{} {}{}", self.id, path, flag)) } - Ok(()) } - fn buffer_close( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - let view = view!(cx.editor); - let doc_id = view.doc; - cx.editor.close_document(doc_id, false)?; - Ok(()) - } + let new_meta = |doc: &Document| BufferMeta { + id: doc.id(), + path: doc.path().cloned(), + is_modified: doc.is_modified(), + is_current: doc.id() == current, + }; - fn force_buffer_close( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - let view = view!(cx.editor); - let doc_id = view.doc; - cx.editor.close_document(doc_id, true)?; - Ok(()) - } + let picker = FilePicker::new( + cx.editor + .documents + .iter() + .map(|(_, doc)| new_meta(doc)) + .collect(), + BufferMeta::format, + |cx, meta, action| { + cx.editor.switch(meta.id, action); + }, + |editor, meta| { + let doc = &editor.documents.get(&meta.id)?; + let &view_id = doc.selections().keys().next()?; + let line = doc + .selection(view_id) + .primary() + .cursor_line(doc.text().slice(..)); + Some((meta.path.clone()?, Some((line, line)))) + }, + ); + cx.push_layer(Box::new(overlayed(picker))); +} - fn write_impl(cx: &mut compositor::Context, path: Option<&Cow>) -> anyhow::Result<()> { - let jobs = &mut cx.jobs; - let doc = doc_mut!(cx.editor); +pub fn command_palette(cx: &mut Context) { + cx.callback = Some(Box::new( + move |compositor: &mut Compositor, cx: &mut compositor::Context| { + let doc = doc_mut!(cx.editor); + let keymap = + compositor.find::().unwrap().keymaps.map()[&doc.mode].reverse_map(); - if let Some(ref path) = path { - doc.set_path(Some(path.as_ref().as_ref())) - .context("invalid filepath")?; - } - if doc.path().is_none() { - bail!("cannot write a buffer without a filename"); - } - let fmt = doc.auto_format().map(|fmt| { - let shared = fmt.shared(); - let callback = make_format_callback( - doc.id(), - doc.version(), - Modified::SetUnmodified, - shared.clone(), + let mut commands: Vec = MappableCommand::STATIC_COMMAND_LIST.into(); + commands.extend(typed::TYPABLE_COMMAND_LIST.iter().map(|cmd| { + MappableCommand::Typable { + name: cmd.name.to_owned(), + doc: cmd.doc.to_owned(), + args: Vec::new(), + } + })); + + // formats key bindings, multiple bindings are comma separated, + // individual key presses are joined with `+` + let fmt_binding = |bindings: &Vec>| -> String { + bindings + .iter() + .map(|bind| { + bind.iter() + .map(|key| key.to_string()) + .collect::>() + .join("+") + }) + .collect::>() + .join(", ") + }; + + let picker = Picker::new( + commands, + move |command| match command { + MappableCommand::Typable { doc, name, .. } => match keymap.get(name as &String) + { + Some(bindings) => format!("{} ({})", doc, fmt_binding(bindings)).into(), + None => doc.into(), + }, + MappableCommand::Static { doc, name, .. } => match keymap.get(*name) { + Some(bindings) => format!("{} ({})", doc, fmt_binding(bindings)).into(), + None => (*doc).into(), + }, + }, + move |cx, command, _action| { + let mut ctx = Context { + register: None, + count: std::num::NonZeroUsize::new(1), + editor: cx.editor, + callback: None, + on_next_key_callback: None, + jobs: cx.jobs, + }; + command.execute(&mut ctx); + }, ); - jobs.callback(callback); - shared - }); - let future = doc.format_and_save(fmt); - cx.jobs.add(Job::new(future).wait_before_exiting()); + compositor.push(Box::new(picker)); + }, + )); +} - if path.is_some() { - let id = doc.id(); - let _ = cx.editor.refresh_language_server(id); +fn last_picker(cx: &mut Context) { + // TODO: last picker does not seem to work well with buffer_picker + cx.callback = Some(Box::new(|compositor: &mut Compositor, _| { + if let Some(picker) = compositor.last_picker.take() { + compositor.push(picker); } - Ok(()) - } + // XXX: figure out how to show error when no last picker lifetime + // cx.editor.set_error("no last picker") + })); +} - fn write( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - write_impl(cx, args.first()) - } +// I inserts at the first nonwhitespace character of each line with a selection +fn prepend_to_line(cx: &mut Context) { + goto_first_nonwhitespace(cx); + let doc = doc_mut!(cx.editor); + enter_insert_mode(doc); +} - fn new_file( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - cx.editor.new_file(Action::Replace); +// A inserts at the end of each line with a selection +fn append_to_line(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + enter_insert_mode(doc); - Ok(()) - } + let selection = doc.selection(view.id).clone().transform(|range| { + let text = doc.text().slice(..); + let line = range.cursor_line(text); + let pos = line_end_char_index(&text, line); + Range::new(pos, pos) + }); + doc.set_selection(view.id, selection); +} - fn format( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - let doc = doc!(cx.editor); - if let Some(format) = doc.format() { - let callback = - make_format_callback(doc.id(), doc.version(), Modified::LeaveModified, format); - cx.jobs.callback(callback); - } +/// Sometimes when applying formatting changes we want to mark the buffer as unmodified, for +/// example because we just applied the same changes while saving. +enum Modified { + SetUnmodified, + LeaveModified, +} - Ok(()) - } - fn set_indent_style( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - use IndentStyle::*; - - // If no argument, report current indent style. - if args.is_empty() { - let style = doc!(cx.editor).indent_style; - cx.editor.set_status(match style { - Tabs => "tabs".to_owned(), - Spaces(1) => "1 space".to_owned(), - Spaces(n) if (2..=8).contains(&n) => format!("{} spaces", n), - _ => unreachable!(), // Shouldn't happen. - }); - return Ok(()); +// Creates an LspCallback that waits for formatting changes to be computed. When they're done, +// it applies them, but only if the doc hasn't changed. +// +// TODO: provide some way to cancel this, probably as part of a more general job cancellation +// scheme +async fn make_format_callback( + doc_id: DocumentId, + doc_version: i32, + modified: Modified, + format: impl Future + Send + 'static, +) -> anyhow::Result { + let format = format.await; + let call: job::Callback = Box::new(move |editor, _compositor| { + let view_id = view!(editor).id; + if let Some(doc) = editor.document_mut(doc_id) { + if doc.version() == doc_version { + doc.apply(&Transaction::from(format), view_id); + doc.append_changes_to_history(view_id); + if let Modified::SetUnmodified = modified { + doc.reset_modified(); + } + } else { + log::info!("discarded formatting changes because the document changed"); + } } + }); + Ok(call) +} - // Attempt to parse argument as an indent style. - let style = match args.get(0) { - Some(arg) if "tabs".starts_with(&arg.to_lowercase()) => Some(Tabs), - Some(Cow::Borrowed("0")) => Some(Tabs), - Some(arg) => arg - .parse::() - .ok() - .filter(|n| (1..=8).contains(n)) - .map(Spaces), - _ => None, - }; +enum Open { + Below, + Above, +} - let style = style.context("invalid indent style")?; - let doc = doc_mut!(cx.editor); - doc.indent_style = style; +fn open(cx: &mut Context, open: Open) { + let count = cx.count(); + let (view, doc) = current!(cx.editor); + enter_insert_mode(doc); - Ok(()) - } + let text = doc.text().slice(..); + let contents = doc.text(); + let selection = doc.selection(view.id); - /// Sets or reports the current document's line ending setting. - fn set_line_ending( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - use LineEnding::*; - - // If no argument, report current line ending setting. - if args.is_empty() { - let line_ending = doc!(cx.editor).line_ending; - cx.editor.set_status(match line_ending { - Crlf => "crlf", - LF => "line feed", - FF => "form feed", - CR => "carriage return", - Nel => "next line", - - // These should never be a document's default line ending. - VT | LS | PS => "error", - }); + let mut ranges = SmallVec::with_capacity(selection.len()); + let mut offs = 0; - return Ok(()); - } + let mut transaction = Transaction::change_by_selection(contents, selection, |range| { + let cursor_line = range.cursor_line(text); - let arg = args - .get(0) - .context("argument missing")? - .to_ascii_lowercase(); - - // Attempt to parse argument as a line ending. - let line_ending = match arg { - // We check for CR first because it shares a common prefix with CRLF. - arg if arg.starts_with("cr") => CR, - arg if arg.starts_with("crlf") => Crlf, - arg if arg.starts_with("lf") => LF, - arg if arg.starts_with("ff") => FF, - arg if arg.starts_with("nel") => Nel, - _ => bail!("invalid line ending"), + let new_line = match open { + // adjust position to the end of the line (next line - 1) + Open::Below => cursor_line + 1, + // adjust position to the end of the previous line (current line - 1) + Open::Above => cursor_line, }; - doc_mut!(cx.editor).line_ending = line_ending; - Ok(()) - } - - fn earlier( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - let uk = args.join(" ").parse::().map_err(|s| anyhow!(s))?; + // Index to insert newlines after, as well as the char width + // to use to compensate for those inserted newlines. + let (line_end_index, line_end_offset_width) = if new_line == 0 { + (0, 0) + } else { + ( + line_end_char_index(&doc.text().slice(..), new_line.saturating_sub(1)), + doc.line_ending.len_chars(), + ) + }; - let (view, doc) = current!(cx.editor); - let success = doc.earlier(view.id, uk); - if !success { - cx.editor.set_status("Already at oldest change"); - } - - Ok(()) - } - - fn later( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - let uk = args.join(" ").parse::().map_err(|s| anyhow!(s))?; - let (view, doc) = current!(cx.editor); - let success = doc.later(view.id, uk); - if !success { - cx.editor.set_status("Already at newest change"); - } - - Ok(()) - } - - fn write_quit( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, - ) -> anyhow::Result<()> { - write_impl(cx, args.first())?; - quit(cx, &[], event) - } - - fn force_write_quit( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, - ) -> anyhow::Result<()> { - write_impl(cx, args.first())?; - force_quit(cx, &[], event) - } - - /// Results an error if there are modified buffers remaining and sets editor error, - /// otherwise returns `Ok(())` - pub(super) fn buffers_remaining_impl(editor: &mut Editor) -> anyhow::Result<()> { - let modified: Vec<_> = editor - .documents() - .filter(|doc| doc.is_modified()) - .map(|doc| { - doc.relative_path() - .map(|path| path.to_string_lossy().to_string()) - .unwrap_or_else(|| SCRATCH_BUFFER_NAME.into()) - }) - .collect(); - if !modified.is_empty() { - bail!( - "{} unsaved buffer(s) remaining: {:?}", - modified.len(), - modified - ); - } - Ok(()) - } - - fn write_all_impl( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - quit: bool, - force: bool, - ) -> anyhow::Result<()> { - let mut errors = String::new(); - let jobs = &mut cx.jobs; - // save all documents - for doc in &mut cx.editor.documents.values_mut() { - if doc.path().is_none() { - errors.push_str("cannot write a buffer without a filename\n"); - continue; - } - - if !doc.is_modified() { - continue; - } - - let fmt = doc.auto_format().map(|fmt| { - let shared = fmt.shared(); - let callback = make_format_callback( - doc.id(), - doc.version(), - Modified::SetUnmodified, - shared.clone(), - ); - jobs.callback(callback); - shared - }); - let future = doc.format_and_save(fmt); - jobs.add(Job::new(future).wait_before_exiting()); - } - - if quit { - if !force { - buffers_remaining_impl(cx.editor)?; - } - - // close all views - let views: Vec<_> = cx.editor.tree.views().map(|(view, _)| view.id).collect(); - for view_id in views { - cx.editor.close(view_id); - } - } - - bail!(errors) - } - - fn write_all( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, - ) -> anyhow::Result<()> { - write_all_impl(cx, args, event, false, false) - } - - fn write_all_quit( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, - ) -> anyhow::Result<()> { - write_all_impl(cx, args, event, true, false) - } - - fn force_write_all_quit( - cx: &mut compositor::Context, - args: &[Cow], - event: PromptEvent, - ) -> anyhow::Result<()> { - write_all_impl(cx, args, event, true, true) - } - - fn quit_all_impl(editor: &mut Editor, force: bool) -> anyhow::Result<()> { - if !force { - buffers_remaining_impl(editor)?; - } - - // close all views - let views: Vec<_> = editor.tree.views().map(|(view, _)| view.id).collect(); - for view_id in views { - editor.close(view_id); - } - - Ok(()) - } - - fn quit_all( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - quit_all_impl(cx.editor, false) - } - - fn force_quit_all( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - quit_all_impl(cx.editor, true) - } - - fn cquit( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - let exit_code = args - .first() - .and_then(|code| code.parse::().ok()) - .unwrap_or(1); - cx.editor.exit_code = exit_code; - - quit_all_impl(cx.editor, false) - } - - fn force_cquit( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - let exit_code = args - .first() - .and_then(|code| code.parse::().ok()) - .unwrap_or(1); - cx.editor.exit_code = exit_code; - - quit_all_impl(cx.editor, true) - } - - fn theme( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - let theme = args.first().context("Theme not provided")?; - let theme = cx - .editor - .theme_loader - .load(theme) - .with_context(|| format!("Failed setting theme {}", theme))?; - let true_color = cx.editor.config.true_color || crate::true_color(); - if !(true_color || theme.is_16_color()) { - bail!("Unsupported theme: theme requires true color support"); - } - cx.editor.set_theme(theme); - Ok(()) - } - - fn yank_main_selection_to_clipboard( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - yank_main_selection_to_clipboard_impl(cx.editor, ClipboardType::Clipboard) - } - - fn yank_joined_to_clipboard( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - let doc = doc!(cx.editor); - let default_sep = Cow::Borrowed(doc.line_ending.as_str()); - let separator = args.first().unwrap_or(&default_sep); - yank_joined_to_clipboard_impl(cx.editor, separator, ClipboardType::Clipboard) - } - - fn yank_main_selection_to_primary_clipboard( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - yank_main_selection_to_clipboard_impl(cx.editor, ClipboardType::Selection) - } - - fn yank_joined_to_primary_clipboard( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - let doc = doc!(cx.editor); - let default_sep = Cow::Borrowed(doc.line_ending.as_str()); - let separator = args.first().unwrap_or(&default_sep); - yank_joined_to_clipboard_impl(cx.editor, separator, ClipboardType::Selection) - } - - fn paste_clipboard_after( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - paste_clipboard_impl(cx.editor, Paste::After, ClipboardType::Clipboard, 1) - } - - fn paste_clipboard_before( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - paste_clipboard_impl(cx.editor, Paste::After, ClipboardType::Clipboard, 1) - } - - fn paste_primary_clipboard_after( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - paste_clipboard_impl(cx.editor, Paste::After, ClipboardType::Selection, 1) - } - - fn paste_primary_clipboard_before( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - paste_clipboard_impl(cx.editor, Paste::After, ClipboardType::Selection, 1) - } - - fn replace_selections_with_clipboard_impl( - cx: &mut compositor::Context, - clipboard_type: ClipboardType, - ) -> anyhow::Result<()> { - let (view, doc) = current!(cx.editor); - - match cx.editor.clipboard_provider.get_contents(clipboard_type) { - Ok(contents) => { - let selection = doc.selection(view.id); - let transaction = - Transaction::change_by_selection(doc.text(), selection, |range| { - (range.from(), range.to(), Some(contents.as_str().into())) - }); - - doc.apply(&transaction, view.id); - doc.append_changes_to_history(view.id); - Ok(()) - } - Err(e) => Err(e.context("Couldn't get system clipboard contents")), - } - } - - fn replace_selections_with_clipboard( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - replace_selections_with_clipboard_impl(cx, ClipboardType::Clipboard) - } - - fn replace_selections_with_primary_clipboard( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - replace_selections_with_clipboard_impl(cx, ClipboardType::Selection) - } - - fn show_clipboard_provider( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - cx.editor - .set_status(cx.editor.clipboard_provider.name().to_string()); - Ok(()) - } - - fn change_current_directory( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - let dir = helix_core::path::expand_tilde( - args.first() - .context("target directory not provided")? - .as_ref() - .as_ref(), - ); - - if let Err(e) = std::env::set_current_dir(dir) { - bail!("Couldn't change the current working directory: {}", e); - } - - let cwd = std::env::current_dir().context("Couldn't get the new working directory")?; - cx.editor.set_status(format!( - "Current working directory is now {}", - cwd.display() - )); - Ok(()) - } - - fn show_current_directory( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - let cwd = std::env::current_dir().context("Couldn't get the new working directory")?; - cx.editor - .set_status(format!("Current working directory is {}", cwd.display())); - Ok(()) - } - - /// Sets the [`Document`]'s encoding.. - fn set_encoding( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - let doc = doc_mut!(cx.editor); - if let Some(label) = args.first() { - doc.set_encoding(label) - } else { - let encoding = doc.encoding().name().to_owned(); - cx.editor.set_status(encoding); - Ok(()) - } - } - - /// Reload the [`Document`] from its source file. - fn reload( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - let (view, doc) = current!(cx.editor); - doc.reload(view.id) - } - - fn tree_sitter_scopes( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - let (view, doc) = current!(cx.editor); - let text = doc.text().slice(..); - - let pos = doc.selection(view.id).primary().cursor(text); - let scopes = indent::get_scopes(doc.syntax(), text, pos); - cx.editor.set_status(format!("scopes: {:?}", &scopes)); - Ok(()) - } - - fn vsplit( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - let id = view!(cx.editor).doc; - - if args.is_empty() { - cx.editor.switch(id, Action::VerticalSplit); - } else { - for arg in args { - cx.editor - .open(PathBuf::from(arg.as_ref()), Action::VerticalSplit)?; - } - } - - Ok(()) - } - - fn hsplit( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - let id = view!(cx.editor).doc; - - if args.is_empty() { - cx.editor.switch(id, Action::HorizontalSplit); - } else { - for arg in args { - cx.editor - .open(PathBuf::from(arg.as_ref()), Action::HorizontalSplit)?; - } - } - - Ok(()) - } - - fn debug_eval( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - if let Some(debugger) = cx.editor.debugger.as_mut() { - let (frame, thread_id) = match (debugger.active_frame, debugger.thread_id) { - (Some(frame), Some(thread_id)) => (frame, thread_id), - _ => { - bail!("Cannot find current stack frame to access variables") - } - }; - - // TODO: support no frame_id - - let frame_id = debugger.stack_frames[&thread_id][frame].id; - let response = block_on(debugger.eval(args.join(" "), Some(frame_id)))?; - cx.editor.set_status(response.result); - } - Ok(()) - } - - fn debug_start( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - let mut args = args.to_owned(); - let name = match args.len() { - 0 => None, - _ => Some(args.remove(0)), - }; - dap_start_impl(cx, name.as_deref(), None, Some(args)) - } - - fn debug_remote( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - let mut args = args.to_owned(); - let address = match args.len() { - 0 => None, - _ => Some(args.remove(0).parse()?), - }; - let name = match args.len() { - 0 => None, - _ => Some(args.remove(0)), - }; - dap_start_impl(cx, name.as_deref(), address, Some(args)) - } - - fn tutor( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - let path = helix_core::runtime_dir().join("tutor.txt"); - cx.editor.open(path, Action::Replace)?; - // Unset path to prevent accidentally saving to the original tutor file. - doc_mut!(cx.editor).set_path(None)?; - Ok(()) - } - - pub(super) fn goto_line_number( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - ensure!(!args.is_empty(), "Line number required"); - - let line = args[0].parse::()?; - - goto_line_impl(cx.editor, NonZeroUsize::new(line)); - - let (view, doc) = current!(cx.editor); - - view.ensure_cursor_in_view(doc, line); - Ok(()) - } - - fn setting( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - let runtime_config = &mut cx.editor.config; - - if args.len() != 2 { - anyhow::bail!("Bad arguments. Usage: `:set key field`"); - } - - let (key, arg) = (&args[0].to_lowercase(), &args[1]); - - match key.as_ref() { - "scrolloff" => runtime_config.scrolloff = arg.parse()?, - "scroll-lines" => runtime_config.scroll_lines = arg.parse()?, - "mouse" => runtime_config.mouse = arg.parse()?, - "line-number" => runtime_config.line_number = arg.parse()?, - "middle-click_paste" => runtime_config.middle_click_paste = arg.parse()?, - "auto-pairs" => runtime_config.auto_pairs = arg.parse()?, - "auto-completion" => runtime_config.auto_completion = arg.parse()?, - "completion-trigger-len" => runtime_config.completion_trigger_len = arg.parse()?, - "auto-info" => runtime_config.auto_info = arg.parse()?, - "true-color" => runtime_config.true_color = arg.parse()?, - "search.smart-case" => runtime_config.search.smart_case = arg.parse()?, - "search.wrap-around" => runtime_config.search.wrap_around = arg.parse()?, - _ => anyhow::bail!("Unknown key `{}`.", args[0]), - } - - Ok(()) - } - - fn sort( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - sort_impl(cx, args, false) - } - - fn sort_reverse( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - sort_impl(cx, args, true) - } - - fn sort_impl( - cx: &mut compositor::Context, - _args: &[Cow], - reverse: bool, - ) -> anyhow::Result<()> { - let (view, doc) = current!(cx.editor); - let text = doc.text().slice(..); - - let selection = doc.selection(view.id); - - let mut fragments: Vec<_> = selection - .fragments(text) - .map(|fragment| Tendril::from(fragment.as_ref())) - .collect(); - - fragments.sort_by(match reverse { - true => |a: &Tendril, b: &Tendril| b.cmp(a), - false => |a: &Tendril, b: &Tendril| a.cmp(b), - }); - - let transaction = Transaction::change( - doc.text(), - selection - .into_iter() - .zip(fragments) - .map(|(s, fragment)| (s.from(), s.to(), Some(fragment))), - ); - - doc.apply(&transaction, view.id); - doc.append_changes_to_history(view.id); - - Ok(()) - } - - fn tree_sitter_subtree( - cx: &mut compositor::Context, - _args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - let (view, doc) = current!(cx.editor); - - if let Some(syntax) = doc.syntax() { - let primary_selection = doc.selection(view.id).primary(); - let text = doc.text(); - let from = text.char_to_byte(primary_selection.from()); - let to = text.char_to_byte(primary_selection.to()); - if let Some(selected_node) = syntax - .tree() - .root_node() - .descendant_for_byte_range(from, to) - { - let contents = format!("```tsq\n{}\n```", selected_node.to_sexp()); - - let callback = async move { - let call: job::Callback = - Box::new(move |editor: &mut Editor, compositor: &mut Compositor| { - let contents = ui::Markdown::new(contents, editor.syn_loader.clone()); - let popup = Popup::new("hover", contents); - compositor.replace_or_push("hover", Box::new(popup)); - }); - Ok(call) - }; - - cx.jobs.callback(callback); - } - } - - Ok(()) - } - - fn help( - cx: &mut compositor::Context, - args: &[Cow], - _event: PromptEvent, - ) -> anyhow::Result<()> { - if args.is_empty() { - // TODO: Open a list of commands? - todo!() - } - - if args[0] == "topics" { - let dir_path = helix_core::runtime_dir().join("help/topics"); - let entries: Vec = std::fs::read_dir(dir_path) - .map(|entries| { - entries - .filter_map(|entry| { - let entry = entry.ok()?; - let path = entry.path(); - Some(path) - }) - .collect() - }) - .unwrap_or_default(); - - let callback = Box::pin(async move { - let call: job::Callback = - Box::new(move |_editor: &mut Editor, compositor: &mut Compositor| { - let picker = FilePicker::new( - entries, - |path| { - path.file_stem() - .and_then(|s| s.to_str()) - .map(From::from) - .unwrap_or_default() - }, - |cx, path, _action| { - if let Err(e) = cx - .editor - .open(path.clone(), Action::HorizontalSplit) - .and_then(|id| { - cx.editor - .document_mut(id) - .unwrap() - .set_path(None) - .map_err(Into::into) - }) - { - cx.editor.set_error(e.to_string()); - } - }, - |_editor, path| Some((path.clone(), None)), - ); - compositor.push(Box::new(picker)); - }); - Ok(call) - }); - cx.jobs.callback(callback); - - return Ok(()); - } - - let args_msg = args.join(" "); - let open_help = - move |help_dir: &str, command: &str, editor: &mut Editor| -> anyhow::Result<()> { - let mut path = helix_core::runtime_dir(); - path.push("help"); - path.push(help_dir); - path.push(format!("{}.txt", command)); - - ensure!(path.is_file(), "No help available for '{}'", args_msg); - let id = editor.open(path, Action::HorizontalSplit)?; - editor.document_mut(id).unwrap().set_path(None)?; - Ok(()) - }; - - const STATIC_HELP_DIR: &str = "static-commands"; - const TYPABLE_HELP_DIR: &str = "typable-commands"; - - let (help_dir, command): (&str, &str) = { - let arg = &args[0]; - if let Some(command) = arg.strip_prefix(':').and_then(|arg| { - TYPABLE_COMMAND_LIST.iter().find_map(|command| { - (command.name == arg || command.aliases.iter().any(|alias| *alias == arg)) - .then(|| command.name) - }) - }) { - (TYPABLE_HELP_DIR, command) - } else if MappableCommand::STATIC_COMMAND_LIST - .iter() - .any(|command| command.name() == arg) - { - (STATIC_HELP_DIR, arg) - } else { - let arg = arg.to_owned().into_owned(); - let keys = arg - .parse::() - .map(|key| vec![key]) - .or_else(|_| helix_view::input::parse_macro(&arg))?; - let callback = Box::pin(async move { - let call: job::Callback = - Box::new(move |editor: &mut Editor, compositor: &mut Compositor| { - use crate::keymap::KeymapResultKind; - let editor_view = compositor.find::().unwrap(); - let mode = doc!(editor).mode; - let keymap = editor_view.keymaps.get_mut(&mode).unwrap(); - let (keys, last_key) = (&keys[..keys.len() - 1], keys.last().unwrap()); - keys.iter().for_each(|key| { - keymap.get(*key); - }); - let result = keymap.get(*last_key); - let res: anyhow::Result<(&str, &str)> = match &result.kind { - KeymapResultKind::Matched(command) => match command { - MappableCommand::Static { name, .. } => { - Ok((STATIC_HELP_DIR, name)) - } - MappableCommand::Typable { name, .. } => { - Ok((TYPABLE_HELP_DIR, name)) - } - }, - KeymapResultKind::NotFound | KeymapResultKind::Cancelled(_) => { - Err(anyhow!("No command found for '{}'", arg)) - } - _ => todo!(), - }; - if let Err(e) = res.and_then(|(help_dir, command)| { - open_help(help_dir, command, editor) - }) { - editor.set_error(e.to_string()); - } - }); - Ok(call) - }); - cx.jobs.callback(callback); - return Ok(()); - } - }; - - open_help(help_dir, command, cx.editor) - } - - pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ - TypableCommand { - name: "quit", - aliases: &["q"], - doc: "Close the current view.", - fun: quit, - completer: None, - }, - TypableCommand { - name: "quit!", - aliases: &["q!"], - doc: "Close the current view forcefully (ignoring unsaved changes).", - fun: force_quit, - completer: None, - }, - TypableCommand { - name: "open", - aliases: &["o"], - doc: "Open a file from disk into the current view.", - fun: open, - completer: Some(completers::filename), - }, - TypableCommand { - name: "buffer-close", - aliases: &["bc", "bclose"], - doc: "Close the current buffer.", - fun: buffer_close, - completer: None, // FIXME: buffer completer - }, - TypableCommand { - name: "buffer-close!", - aliases: &["bc!", "bclose!"], - doc: "Close the current buffer forcefully (ignoring unsaved changes).", - fun: force_buffer_close, - completer: None, // FIXME: buffer completer - }, - TypableCommand { - name: "write", - aliases: &["w"], - doc: "Write changes to disk. Accepts an optional path (:write some/path.txt)", - fun: write, - completer: Some(completers::filename), - }, - TypableCommand { - name: "new", - aliases: &["n"], - doc: "Create a new scratch buffer.", - fun: new_file, - completer: Some(completers::filename), - }, - TypableCommand { - name: "format", - aliases: &["fmt"], - doc: "Format the file using the LSP formatter.", - fun: format, - completer: None, - }, - TypableCommand { - name: "indent-style", - aliases: &[], - doc: "Set the indentation style for editing. ('t' for tabs or 1-8 for number of spaces.)", - fun: set_indent_style, - completer: None, - }, - TypableCommand { - name: "line-ending", - aliases: &[], - doc: "Set the document's default line ending. Options: crlf, lf, cr, ff, nel.", - fun: set_line_ending, - completer: None, - }, - TypableCommand { - name: "earlier", - aliases: &["ear"], - doc: "Jump back to an earlier point in edit history. Accepts a number of steps or a time span.", - fun: earlier, - completer: None, - }, - TypableCommand { - name: "later", - aliases: &["lat"], - doc: "Jump to a later point in edit history. Accepts a number of steps or a time span.", - fun: later, - completer: None, - }, - TypableCommand { - name: "write-quit", - aliases: &["wq", "x"], - doc: "Write changes to disk and close the current view. Accepts an optional path (:wq some/path.txt)", - fun: write_quit, - completer: Some(completers::filename), - }, - TypableCommand { - name: "write-quit!", - aliases: &["wq!", "x!"], - doc: "Write changes to disk and close the current view forcefully. Accepts an optional path (:wq! some/path.txt)", - fun: force_write_quit, - completer: Some(completers::filename), - }, - TypableCommand { - name: "write-all", - aliases: &["wa"], - doc: "Write changes from all views to disk.", - fun: write_all, - completer: None, - }, - TypableCommand { - name: "write-quit-all", - aliases: &["wqa", "xa"], - doc: "Write changes from all views to disk and close all views.", - fun: write_all_quit, - completer: None, - }, - TypableCommand { - name: "write-quit-all!", - aliases: &["wqa!", "xa!"], - doc: "Write changes from all views to disk and close all views forcefully (ignoring unsaved changes).", - fun: force_write_all_quit, - completer: None, - }, - TypableCommand { - name: "quit-all", - aliases: &["qa"], - doc: "Close all views.", - fun: quit_all, - completer: None, - }, - TypableCommand { - name: "quit-all!", - aliases: &["qa!"], - doc: "Close all views forcefully (ignoring unsaved changes).", - fun: force_quit_all, - completer: None, - }, - TypableCommand { - name: "cquit", - aliases: &["cq"], - doc: "Quit with exit code (default 1). Accepts an optional integer exit code (:cq 2).", - fun: cquit, - completer: None, - }, - TypableCommand { - name: "cquit!", - aliases: &["cq!"], - doc: "Quit with exit code (default 1) forcefully (ignoring unsaved changes). Accepts an optional integer exit code (:cq! 2).", - fun: force_cquit, - completer: None, - }, - TypableCommand { - name: "theme", - aliases: &[], - doc: "Change the editor theme.", - fun: theme, - completer: Some(completers::theme), - }, - TypableCommand { - name: "clipboard-yank", - aliases: &[], - doc: "Yank main selection into system clipboard.", - fun: yank_main_selection_to_clipboard, - completer: None, - }, - TypableCommand { - name: "clipboard-yank-join", - aliases: &[], - doc: "Yank joined selections into system clipboard. A separator can be provided as first argument. Default value is newline.", // FIXME: current UI can't display long doc. - fun: yank_joined_to_clipboard, - completer: None, - }, - TypableCommand { - name: "primary-clipboard-yank", - aliases: &[], - doc: "Yank main selection into system primary clipboard.", - fun: yank_main_selection_to_primary_clipboard, - completer: None, - }, - TypableCommand { - name: "primary-clipboard-yank-join", - aliases: &[], - doc: "Yank joined selections into system primary clipboard. A separator can be provided as first argument. Default value is newline.", // FIXME: current UI can't display long doc. - fun: yank_joined_to_primary_clipboard, - completer: None, - }, - TypableCommand { - name: "clipboard-paste-after", - aliases: &[], - doc: "Paste system clipboard after selections.", - fun: paste_clipboard_after, - completer: None, - }, - TypableCommand { - name: "clipboard-paste-before", - aliases: &[], - doc: "Paste system clipboard before selections.", - fun: paste_clipboard_before, - completer: None, - }, - TypableCommand { - name: "clipboard-paste-replace", - aliases: &[], - doc: "Replace selections with content of system clipboard.", - fun: replace_selections_with_clipboard, - completer: None, - }, - TypableCommand { - name: "primary-clipboard-paste-after", - aliases: &[], - doc: "Paste primary clipboard after selections.", - fun: paste_primary_clipboard_after, - completer: None, - }, - TypableCommand { - name: "primary-clipboard-paste-before", - aliases: &[], - doc: "Paste primary clipboard before selections.", - fun: paste_primary_clipboard_before, - completer: None, - }, - TypableCommand { - name: "primary-clipboard-paste-replace", - aliases: &[], - doc: "Replace selections with content of system primary clipboard.", - fun: replace_selections_with_primary_clipboard, - completer: None, - }, - TypableCommand { - name: "show-clipboard-provider", - aliases: &[], - doc: "Show clipboard provider name in status bar.", - fun: show_clipboard_provider, - completer: None, - }, - TypableCommand { - name: "change-current-directory", - aliases: &["cd"], - doc: "Change the current working directory.", - fun: change_current_directory, - completer: Some(completers::directory), - }, - TypableCommand { - name: "show-directory", - aliases: &["pwd"], - doc: "Show the current working directory.", - fun: show_current_directory, - completer: None, - }, - TypableCommand { - name: "encoding", - aliases: &[], - doc: "Set encoding based on `https://encoding.spec.whatwg.org`", - fun: set_encoding, - completer: None, - }, - TypableCommand { - name: "reload", - aliases: &[], - doc: "Discard changes and reload from the source file.", - fun: reload, - completer: None, - }, - TypableCommand { - name: "tree-sitter-scopes", - aliases: &[], - doc: "Display tree sitter scopes, primarily for theming and development.", - fun: tree_sitter_scopes, - completer: None, - }, - TypableCommand { - name: "debug-start", - aliases: &["dbg"], - doc: "Start a debug session from a given template with given parameters.", - fun: debug_start, - completer: None, - }, - TypableCommand { - name: "debug-remote", - aliases: &["dbg-tcp"], - doc: "Connect to a debug adapter by TCP address and start a debugging session from a given template with given parameters.", - fun: debug_remote, - completer: None, - }, - TypableCommand { - name: "debug-eval", - aliases: &[], - doc: "Evaluate expression in current debug context.", - fun: debug_eval, - completer: None, - }, - TypableCommand { - name: "vsplit", - aliases: &["vs"], - doc: "Open the file in a vertical split.", - fun: vsplit, - completer: Some(completers::filename), - }, - TypableCommand { - name: "hsplit", - aliases: &["hs", "sp"], - doc: "Open the file in a horizontal split.", - fun: hsplit, - completer: Some(completers::filename), - }, - TypableCommand { - name: "tutor", - aliases: &[], - doc: "Open the tutorial.", - fun: tutor, - completer: None, - }, - TypableCommand { - name: "goto", - aliases: &["g"], - doc: "Go to line number.", - fun: goto_line_number, - completer: None, - }, - TypableCommand { - name: "set-option", - aliases: &["set"], - doc: "Set a config option at runtime", - fun: setting, - completer: Some(completers::setting), - }, - TypableCommand { - name: "sort", - aliases: &[], - doc: "Sort ranges in selection.", - fun: sort, - completer: None, - }, - TypableCommand { - name: "rsort", - aliases: &[], - doc: "Sort ranges in selection in reverse order.", - fun: sort_reverse, - completer: None, - }, - TypableCommand { - name: "tree-sitter-subtree", - aliases: &["ts-subtree"], - doc: "Display tree sitter subtree under cursor, primarily for debugging queries.", - fun: tree_sitter_subtree, - completer: None, - }, - TypableCommand { - name: "help", - aliases: &["h"], - doc: "Open documentation for a command or keybind.", - fun: help, - completer: Some(completers::help), - }, - ]; - - pub static TYPABLE_COMMAND_MAP: Lazy> = - Lazy::new(|| { - TYPABLE_COMMAND_LIST - .iter() - .flat_map(|cmd| { - std::iter::once((cmd.name, cmd)) - .chain(cmd.aliases.iter().map(move |&alias| (alias, cmd))) - }) - .collect() - }); -} - -fn command_mode(cx: &mut Context) { - let mut prompt = Prompt::new( - ":".into(), - Some(':'), - |input: &str| { - static FUZZY_MATCHER: Lazy = - Lazy::new(fuzzy_matcher::skim::SkimMatcherV2::default); - - // we use .this over split_whitespace() because we care about empty segments - let parts = input.split(' ').collect::>(); - - // simple heuristic: if there's no just one part, complete command name. - // if there's a space, per command completion kicks in. - if parts.len() <= 1 { - let mut matches: Vec<_> = cmd::TYPABLE_COMMAND_LIST - .iter() - .filter_map(|command| { - FUZZY_MATCHER - .fuzzy_match(command.name, input) - .map(|score| (command.name, score)) - }) - .collect(); - - matches.sort_unstable_by_key(|(_file, score)| std::cmp::Reverse(*score)); - matches - .into_iter() - .map(|(name, _)| (0.., name.into())) - .collect() - } else { - let part = parts.last().unwrap(); - - if let Some(cmd::TypableCommand { - completer: Some(completer), - .. - }) = cmd::TYPABLE_COMMAND_MAP.get(parts[0]) - { - completer(part) - .into_iter() - .map(|(range, file)| { - // offset ranges to input - let offset = input.len() - part.len(); - let range = (range.start + offset)..; - (range, file) - }) - .collect() - } else { - Vec::new() - } - } - }, // completion - move |cx: &mut compositor::Context, input: &str, event: PromptEvent| { - if event != PromptEvent::Validate { - return; - } - - let parts = input.split_whitespace().collect::>(); - if parts.is_empty() { - return; - } - - // If command is numeric, interpret as line number and go there. - if parts.len() == 1 && parts[0].parse::().ok().is_some() { - if let Err(e) = cmd::goto_line_number(cx, &[Cow::from(parts[0])], event) { - cx.editor.set_error(format!("{}", e)); - } - return; - } - - // Handle typable commands - if let Some(cmd) = cmd::TYPABLE_COMMAND_MAP.get(parts[0]) { - let args = if cfg!(unix) { - shellwords::shellwords(input) - } else { - // Windows doesn't support POSIX, so fallback for now - parts - .into_iter() - .map(|part| part.into()) - .collect::>() - }; - - if let Err(e) = (cmd.fun)(cx, &args[1..], event) { - cx.editor.set_error(format!("{}", e)); - } - } else { - cx.editor - .set_error(format!("no such command: '{}'", parts[0])); - }; - }, - ); - prompt.doc_fn = Box::new(|input: &str| { - let part = input.split(' ').next().unwrap_or_default(); - - if let Some(cmd::TypableCommand { doc, .. }) = cmd::TYPABLE_COMMAND_MAP.get(part) { - return Some(doc); - } - - None - }); - - cx.push_layer(Box::new(prompt)); -} - -fn file_picker(cx: &mut Context) { - // We don't specify language markers, root will be the root of the current git repo - let root = find_root(None, &[]).unwrap_or_else(|| PathBuf::from("./")); - let picker = ui::file_picker(root, &cx.editor.config); - cx.push_layer(Box::new(overlayed(picker))); -} - -fn buffer_picker(cx: &mut Context) { - let current = view!(cx.editor).doc; - - struct BufferMeta { - id: DocumentId, - path: Option, - is_modified: bool, - is_current: bool, - } - - impl BufferMeta { - fn format(&self) -> Cow { - let path = self - .path - .as_deref() - .map(helix_core::path::get_relative_path); - let path = match path.as_deref().and_then(Path::to_str) { - Some(path) => path, - None => return Cow::Borrowed(SCRATCH_BUFFER_NAME), - }; - - let mut flags = Vec::new(); - if self.is_modified { - flags.push("+"); - } - if self.is_current { - flags.push("*"); - } - - let flag = if flags.is_empty() { - "".into() - } else { - format!(" ({})", flags.join("")) - }; - Cow::Owned(format!("{}{}", path, flag)) - } - } - - let new_meta = |doc: &Document| BufferMeta { - id: doc.id(), - path: doc.path().cloned(), - is_modified: doc.is_modified(), - is_current: doc.id() == current, - }; - - let picker = FilePicker::new( - cx.editor - .documents - .iter() - .map(|(_, doc)| new_meta(doc)) - .collect(), - BufferMeta::format, - |cx, meta, action| { - cx.editor.switch(meta.id, action); - }, - |editor, meta| { - let doc = &editor.documents.get(&meta.id)?; - let &view_id = doc.selections().keys().next()?; - let line = doc - .selection(view_id) - .primary() - .cursor_line(doc.text().slice(..)); - Some((meta.path.clone()?, Some((line, line)))) - }, - ); - cx.push_layer(Box::new(overlayed(picker))); -} - -fn symbol_picker(cx: &mut Context) { - fn nested_to_flat( - list: &mut Vec, - file: &lsp::TextDocumentIdentifier, - symbol: lsp::DocumentSymbol, - ) { - #[allow(deprecated)] - list.push(lsp::SymbolInformation { - name: symbol.name, - kind: symbol.kind, - tags: symbol.tags, - deprecated: symbol.deprecated, - location: lsp::Location::new(file.uri.clone(), symbol.selection_range), - container_name: None, - }); - for child in symbol.children.into_iter().flatten() { - nested_to_flat(list, file, child); - } - } - let doc = doc!(cx.editor); - - let language_server = match doc.language_server() { - Some(language_server) => language_server, - None => return, - }; - let offset_encoding = language_server.offset_encoding(); - - let future = language_server.document_symbols(doc.identifier()); - - cx.callback( - future, - move |editor: &mut Editor, - compositor: &mut Compositor, - response: Option| { - if let Some(symbols) = response { - // lsp has two ways to represent symbols (flat/nested) - // convert the nested variant to flat, so that we have a homogeneous list - let symbols = match symbols { - lsp::DocumentSymbolResponse::Flat(symbols) => symbols, - lsp::DocumentSymbolResponse::Nested(symbols) => { - let doc = doc!(editor); - let mut flat_symbols = Vec::new(); - for symbol in symbols { - nested_to_flat(&mut flat_symbols, &doc.identifier(), symbol) - } - flat_symbols - } - }; - - let mut picker = FilePicker::new( - symbols, - |symbol| (&symbol.name).into(), - move |cx, symbol, _action| { - push_jump(cx.editor); - let (view, doc) = current!(cx.editor); - - if let Some(range) = - lsp_range_to_range(doc.text(), symbol.location.range, offset_encoding) - { - // we flip the range so that the cursor sits on the start of the symbol - // (for example start of the function). - doc.set_selection(view.id, Selection::single(range.head, range.anchor)); - align_view(doc, view, Align::Center); - } - }, - move |_editor, symbol| { - let path = symbol.location.uri.to_file_path().unwrap(); - let line = Some(( - symbol.location.range.start.line as usize, - symbol.location.range.end.line as usize, - )); - Some((path, line)) - }, - ); - picker.truncate_start = false; - compositor.push(Box::new(overlayed(picker))) - } - }, - ) -} - -fn workspace_symbol_picker(cx: &mut Context) { - let doc = doc!(cx.editor); - let current_path = doc.path().cloned(); - let language_server = match doc.language_server() { - Some(language_server) => language_server, - None => return, - }; - let offset_encoding = language_server.offset_encoding(); - let future = language_server.workspace_symbols("".to_string()); - - cx.callback( - future, - move |_editor: &mut Editor, - compositor: &mut Compositor, - response: Option>| { - if let Some(symbols) = response { - let mut picker = FilePicker::new( - symbols, - move |symbol| { - let path = symbol.location.uri.to_file_path().unwrap(); - if current_path.as_ref().map(|p| p == &path).unwrap_or(false) { - (&symbol.name).into() - } else { - let relative_path = helix_core::path::get_relative_path(path.as_path()) - .to_string_lossy() - .into_owned(); - format!("{} ({})", &symbol.name, relative_path).into() - } - }, - move |cx, symbol, action| { - let path = symbol.location.uri.to_file_path().unwrap(); - cx.editor.open(path, action).expect("editor.open failed"); - let (view, doc) = current!(cx.editor); - - if let Some(range) = - lsp_range_to_range(doc.text(), symbol.location.range, offset_encoding) - { - // we flip the range so that the cursor sits on the start of the symbol - // (for example start of the function). - doc.set_selection(view.id, Selection::single(range.head, range.anchor)); - align_view(doc, view, Align::Center); - } - }, - move |_editor, symbol| { - let path = symbol.location.uri.to_file_path().unwrap(); - let line = Some(( - symbol.location.range.start.line as usize, - symbol.location.range.end.line as usize, - )); - Some((path, line)) - }, - ); - picker.truncate_start = false; - compositor.push(Box::new(overlayed(picker))) - } - }, - ) -} - -impl ui::menu::Item for lsp::CodeActionOrCommand { - fn label(&self) -> &str { - match self { - lsp::CodeActionOrCommand::CodeAction(action) => action.title.as_str(), - lsp::CodeActionOrCommand::Command(command) => command.title.as_str(), - } - } -} - -pub fn code_action(cx: &mut Context) { - let (view, doc) = current!(cx.editor); - - let language_server = match doc.language_server() { - Some(language_server) => language_server, - None => return, - }; - - let range = range_to_lsp_range( - doc.text(), - doc.selection(view.id).primary(), - language_server.offset_encoding(), - ); - - let future = language_server.code_actions(doc.identifier(), range); - let offset_encoding = language_server.offset_encoding(); - - cx.callback( - future, - move |editor: &mut Editor, - compositor: &mut Compositor, - response: Option| { - let actions = match response { - Some(a) => a, - None => return, - }; - if actions.is_empty() { - editor.set_status("No code actions available"); - return; - } - - let mut picker = ui::Menu::new(actions, move |editor, code_action, event| { - if event != PromptEvent::Validate { - return; - } - - // always present here - let code_action = code_action.unwrap(); - - match code_action { - lsp::CodeActionOrCommand::Command(command) => { - log::debug!("code action command: {:?}", command); - execute_lsp_command(editor, command.clone()); - } - lsp::CodeActionOrCommand::CodeAction(code_action) => { - log::debug!("code action: {:?}", code_action); - if let Some(ref workspace_edit) = code_action.edit { - log::debug!("edit: {:?}", workspace_edit); - apply_workspace_edit(editor, offset_encoding, workspace_edit); - } - - // if code action provides both edit and command first the edit - // should be applied and then the command - if let Some(command) = &code_action.command { - execute_lsp_command(editor, command.clone()); - } - } - } - }); - picker.move_down(); // pre-select the first item - - let popup = Popup::new("code-action", picker).margin(helix_view::graphics::Margin { - vertical: 1, - horizontal: 1, - }); - compositor.replace_or_push("code-action", Box::new(popup)); - }, - ) -} - -pub fn execute_lsp_command(editor: &mut Editor, cmd: lsp::Command) { - let doc = doc!(editor); - let language_server = match doc.language_server() { - Some(language_server) => language_server, - None => return, - }; - - // the command is executed on the server and communicated back - // to the client asynchronously using workspace edits - let command_future = language_server.command(cmd); - tokio::spawn(async move { - let res = command_future.await; - - if let Err(e) = res { - log::error!("execute LSP command: {}", e); - } - }); -} - -pub fn apply_document_resource_op(op: &lsp::ResourceOp) -> std::io::Result<()> { - use lsp::ResourceOp; - use std::fs; - match op { - ResourceOp::Create(op) => { - let path = op.uri.to_file_path().unwrap(); - let ignore_if_exists = op.options.as_ref().map_or(false, |options| { - !options.overwrite.unwrap_or(false) && options.ignore_if_exists.unwrap_or(false) - }); - if ignore_if_exists && path.exists() { - Ok(()) - } else { - fs::write(&path, []) - } - } - ResourceOp::Delete(op) => { - let path = op.uri.to_file_path().unwrap(); - if path.is_dir() { - let recursive = op - .options - .as_ref() - .and_then(|options| options.recursive) - .unwrap_or(false); - - if recursive { - fs::remove_dir_all(&path) - } else { - fs::remove_dir(&path) - } - } else if path.is_file() { - fs::remove_file(&path) - } else { - Ok(()) - } - } - ResourceOp::Rename(op) => { - let from = op.old_uri.to_file_path().unwrap(); - let to = op.new_uri.to_file_path().unwrap(); - let ignore_if_exists = op.options.as_ref().map_or(false, |options| { - !options.overwrite.unwrap_or(false) && options.ignore_if_exists.unwrap_or(false) - }); - if ignore_if_exists && to.exists() { - Ok(()) - } else { - fs::rename(&from, &to) - } - } - } -} - -pub fn apply_workspace_edit( - editor: &mut Editor, - offset_encoding: OffsetEncoding, - workspace_edit: &lsp::WorkspaceEdit, -) { - let mut apply_edits = |uri: &helix_lsp::Url, text_edits: Vec| { - let path = uri - .to_file_path() - .expect("unable to convert URI to filepath"); - - let current_view_id = view!(editor).id; - let doc_id = editor.open(path, Action::Load).unwrap(); - let doc = editor - .document_mut(doc_id) - .expect("Document for document_changes not found"); - - // Need to determine a view for apply/append_changes_to_history - let selections = doc.selections(); - let view_id = if selections.contains_key(¤t_view_id) { - // use current if possible - current_view_id - } else { - // Hack: we take the first available view_id - selections - .keys() - .next() - .copied() - .expect("No view_id available") - }; - - let transaction = helix_lsp::util::generate_transaction_from_edits( - doc.text(), - text_edits, - offset_encoding, - ); - doc.apply(&transaction, view_id); - doc.append_changes_to_history(view_id); - }; - - if let Some(ref changes) = workspace_edit.changes { - log::debug!("workspace changes: {:?}", changes); - for (uri, text_edits) in changes { - let text_edits = text_edits.to_vec(); - apply_edits(uri, text_edits); - } - return; - // Not sure if it works properly, it'll be safer to just panic here to avoid breaking some parts of code on which code actions will be used - // TODO: find some example that uses workspace changes, and test it - // for (url, edits) in changes.iter() { - // let file_path = url.origin().ascii_serialization(); - // let file_path = std::path::PathBuf::from(file_path); - // let file = std::fs::File::open(file_path).unwrap(); - // let mut text = Rope::from_reader(file).unwrap(); - // let transaction = edits_to_changes(&text, edits); - // transaction.apply(&mut text); - // } - } - - if let Some(ref document_changes) = workspace_edit.document_changes { - match document_changes { - lsp::DocumentChanges::Edits(document_edits) => { - for document_edit in document_edits { - let edits = document_edit - .edits - .iter() - .map(|edit| match edit { - lsp::OneOf::Left(text_edit) => text_edit, - lsp::OneOf::Right(annotated_text_edit) => { - &annotated_text_edit.text_edit - } - }) - .cloned() - .collect(); - apply_edits(&document_edit.text_document.uri, edits); - } - } - lsp::DocumentChanges::Operations(operations) => { - log::debug!("document changes - operations: {:?}", operations); - for operateion in operations { - match operateion { - lsp::DocumentChangeOperation::Op(op) => { - apply_document_resource_op(op).unwrap(); - } - - lsp::DocumentChangeOperation::Edit(document_edit) => { - let edits = document_edit - .edits - .iter() - .map(|edit| match edit { - lsp::OneOf::Left(text_edit) => text_edit, - lsp::OneOf::Right(annotated_text_edit) => { - &annotated_text_edit.text_edit - } - }) - .cloned() - .collect(); - apply_edits(&document_edit.text_document.uri, edits); - } - } - } - } - } - } -} - -fn last_picker(cx: &mut Context) { - // TODO: last picker does not seem to work well with buffer_picker - cx.callback = Some(Box::new(|compositor: &mut Compositor, _| { - if let Some(picker) = compositor.last_picker.take() { - compositor.push(picker); - } - // XXX: figure out how to show error when no last picker lifetime - // cx.editor.set_error("no last picker") - })); -} - -// I inserts at the first nonwhitespace character of each line with a selection -fn prepend_to_line(cx: &mut Context) { - goto_first_nonwhitespace(cx); - let doc = doc_mut!(cx.editor); - enter_insert_mode(doc); -} - -// A inserts at the end of each line with a selection -fn append_to_line(cx: &mut Context) { - let (view, doc) = current!(cx.editor); - enter_insert_mode(doc); - - let selection = doc.selection(view.id).clone().transform(|range| { - let text = doc.text().slice(..); - let line = range.cursor_line(text); - let pos = line_end_char_index(&text, line); - Range::new(pos, pos) - }); - doc.set_selection(view.id, selection); -} - -/// Sometimes when applying formatting changes we want to mark the buffer as unmodified, for -/// example because we just applied the same changes while saving. -enum Modified { - SetUnmodified, - LeaveModified, -} - -// Creates an LspCallback that waits for formatting changes to be computed. When they're done, -// it applies them, but only if the doc hasn't changed. -// -// TODO: provide some way to cancel this, probably as part of a more general job cancellation -// scheme -async fn make_format_callback( - doc_id: DocumentId, - doc_version: i32, - modified: Modified, - format: impl Future + Send + 'static, -) -> anyhow::Result { - let format = format.await; - let call: job::Callback = Box::new(move |editor: &mut Editor, _compositor: &mut Compositor| { - let view_id = view!(editor).id; - if let Some(doc) = editor.document_mut(doc_id) { - if doc.version() == doc_version { - doc.apply(&Transaction::from(format), view_id); - doc.append_changes_to_history(view_id); - if let Modified::SetUnmodified = modified { - doc.reset_modified(); - } - } else { - log::info!("discarded formatting changes because the document changed"); - } - } - }); - Ok(call) -} - -enum Open { - Below, - Above, -} - -fn open(cx: &mut Context, open: Open) { - let count = cx.count(); - let (view, doc) = current!(cx.editor); - enter_insert_mode(doc); - - let text = doc.text().slice(..); - let contents = doc.text(); - let selection = doc.selection(view.id); - - let mut ranges = SmallVec::with_capacity(selection.len()); - let mut offs = 0; - - let mut transaction = Transaction::change_by_selection(contents, selection, |range| { - let cursor_line = range.cursor_line(text); - - let new_line = match open { - // adjust position to the end of the line (next line - 1) - Open::Below => cursor_line + 1, - // adjust position to the end of the previous line (current line - 1) - Open::Above => cursor_line, - }; - - // Index to insert newlines after, as well as the char width - // to use to compensate for those inserted newlines. - let (line_end_index, line_end_offset_width) = if new_line == 0 { - (0, 0) - } else { - ( - line_end_char_index(&doc.text().slice(..), new_line.saturating_sub(1)), - doc.line_ending.len_chars(), - ) - }; - - // TODO: share logic with insert_newline for indentation - let indent_level = indent::suggested_indent_for_pos( + let indent = indent::indent_for_newline( doc.language_config(), doc.syntax(), + &doc.indent_style, + doc.tab_width(), text, - line_end_index, new_line.saturating_sub(1), - true, - ) - .unwrap_or_else(|| indent::indent_level_for_line(text.line(cursor_line), doc.tab_width())); - let indent = doc.indent_unit().repeat(indent_level); + line_end_index, + cursor_line, + ); let indent_len = indent.len(); let mut text = String::with_capacity(1 + indent_len); text.push_str(doc.line_ending.as_str()); @@ -4240,306 +2398,67 @@ fn goto_last_line(cx: &mut Context) { } fn goto_last_accessed_file(cx: &mut Context) { - let alternate_file = view!(cx.editor).last_accessed_doc; - if let Some(alt) = alternate_file { - cx.editor.switch(alt, Action::Replace); - } else { - cx.editor.set_error("no last accessed buffer") - } -} - -fn goto_last_modification(cx: &mut Context) { - let (view, doc) = current!(cx.editor); - let pos = doc.history.get_mut().last_edit_pos(); - let text = doc.text().slice(..); - if let Some(pos) = pos { - let selection = doc - .selection(view.id) - .clone() - .transform(|range| range.put_cursor(text, pos, doc.mode == Mode::Select)); - doc.set_selection(view.id, selection); - } -} - -fn goto_last_modified_file(cx: &mut Context) { - let view = view!(cx.editor); - let alternate_file = view - .last_modified_docs - .into_iter() - .flatten() - .find(|&id| id != view.doc); - if let Some(alt) = alternate_file { - cx.editor.switch(alt, Action::Replace); - } else { - cx.editor.set_error("no last modified buffer") - } -} - -fn select_mode(cx: &mut Context) { - let (view, doc) = current!(cx.editor); - let text = doc.text().slice(..); - - // Make sure end-of-document selections are also 1-width. - // (With the exception of being in an empty document, of course.) - let selection = doc.selection(view.id).clone().transform(|range| { - if range.is_empty() && range.head == text.len_chars() { - Range::new( - graphemes::prev_grapheme_boundary(text, range.anchor), - range.head, - ) - } else { - range - } - }); - doc.set_selection(view.id, selection); - - doc_mut!(cx.editor).mode = Mode::Select; -} - -fn exit_select_mode(cx: &mut Context) { - let doc = doc_mut!(cx.editor); - if doc.mode == Mode::Select { - doc.mode = Mode::Normal; - } -} - -fn goto_impl( - editor: &mut Editor, - compositor: &mut Compositor, - locations: Vec, - offset_encoding: OffsetEncoding, -) { - push_jump(editor); - - fn jump_to( - editor: &mut Editor, - location: &lsp::Location, - offset_encoding: OffsetEncoding, - action: Action, - ) { - let path = location - .uri - .to_file_path() - .expect("unable to convert URI to filepath"); - let _id = editor.open(path, action).expect("editor.open failed"); - let (view, doc) = current!(editor); - let definition_pos = location.range.start; - // TODO: convert inside server - let new_pos = - if let Some(new_pos) = lsp_pos_to_pos(doc.text(), definition_pos, offset_encoding) { - new_pos - } else { - return; - }; - doc.set_selection(view.id, Selection::point(new_pos)); - align_view(doc, view, Align::Center); - } - - let cwdir = std::env::current_dir().expect("couldn't determine current directory"); - - match locations.as_slice() { - [location] => { - jump_to(editor, location, offset_encoding, Action::Replace); - } - [] => { - editor.set_error("No definition found."); - } - _locations => { - let picker = FilePicker::new( - locations, - move |location| { - let file: Cow<'_, str> = (location.uri.scheme() == "file") - .then(|| { - location - .uri - .to_file_path() - .map(|path| { - // strip root prefix - path.strip_prefix(&cwdir) - .map(|path| path.to_path_buf()) - .unwrap_or(path) - }) - .map(|path| Cow::from(path.to_string_lossy().into_owned())) - .ok() - }) - .flatten() - .unwrap_or_else(|| location.uri.as_str().into()); - let line = location.range.start.line; - format!("{}:{}", file, line).into() - }, - move |cx, location, action| jump_to(cx.editor, location, offset_encoding, action), - |_editor, location| { - let path = location.uri.to_file_path().unwrap(); - let line = Some(( - location.range.start.line as usize, - location.range.end.line as usize, - )); - Some((path, line)) - }, - ); - compositor.push(Box::new(overlayed(picker))); - } - } -} - -fn goto_definition(cx: &mut Context) { - let (view, doc) = current!(cx.editor); - let language_server = match doc.language_server() { - Some(language_server) => language_server, - None => return, - }; - - let offset_encoding = language_server.offset_encoding(); - - let pos = pos_to_lsp_pos( - doc.text(), - doc.selection(view.id) - .primary() - .cursor(doc.text().slice(..)), - offset_encoding, - ); - - let future = language_server.goto_definition(doc.identifier(), pos, None); - - cx.callback( - future, - move |editor: &mut Editor, - compositor: &mut Compositor, - response: Option| { - let items = match response { - Some(lsp::GotoDefinitionResponse::Scalar(location)) => vec![location], - Some(lsp::GotoDefinitionResponse::Array(locations)) => locations, - Some(lsp::GotoDefinitionResponse::Link(locations)) => locations - .into_iter() - .map(|location_link| lsp::Location { - uri: location_link.target_uri, - range: location_link.target_range, - }) - .collect(), - None => Vec::new(), - }; - - goto_impl(editor, compositor, items, offset_encoding); - }, - ); -} - -fn goto_type_definition(cx: &mut Context) { - let (view, doc) = current!(cx.editor); - let language_server = match doc.language_server() { - Some(language_server) => language_server, - None => return, - }; - - let offset_encoding = language_server.offset_encoding(); - - let pos = pos_to_lsp_pos( - doc.text(), - doc.selection(view.id) - .primary() - .cursor(doc.text().slice(..)), - offset_encoding, - ); - - let future = language_server.goto_type_definition(doc.identifier(), pos, None); - - cx.callback( - future, - move |editor: &mut Editor, - compositor: &mut Compositor, - response: Option| { - let items = match response { - Some(lsp::GotoDefinitionResponse::Scalar(location)) => vec![location], - Some(lsp::GotoDefinitionResponse::Array(locations)) => locations, - Some(lsp::GotoDefinitionResponse::Link(locations)) => locations - .into_iter() - .map(|location_link| lsp::Location { - uri: location_link.target_uri, - range: location_link.target_range, - }) - .collect(), - None => Vec::new(), - }; - - goto_impl(editor, compositor, items, offset_encoding); - }, - ); -} - -fn goto_implementation(cx: &mut Context) { - let (view, doc) = current!(cx.editor); - let language_server = match doc.language_server() { - Some(language_server) => language_server, - None => return, - }; - - let offset_encoding = language_server.offset_encoding(); - - let pos = pos_to_lsp_pos( - doc.text(), - doc.selection(view.id) - .primary() - .cursor(doc.text().slice(..)), - offset_encoding, - ); - - let future = language_server.goto_implementation(doc.identifier(), pos, None); - - cx.callback( - future, - move |editor: &mut Editor, - compositor: &mut Compositor, - response: Option| { - let items = match response { - Some(lsp::GotoDefinitionResponse::Scalar(location)) => vec![location], - Some(lsp::GotoDefinitionResponse::Array(locations)) => locations, - Some(lsp::GotoDefinitionResponse::Link(locations)) => locations - .into_iter() - .map(|location_link| lsp::Location { - uri: location_link.target_uri, - range: location_link.target_range, - }) - .collect(), - None => Vec::new(), - }; - - goto_impl(editor, compositor, items, offset_encoding); - }, - ); + let alternate_file = view!(cx.editor).last_accessed_doc; + if let Some(alt) = alternate_file { + cx.editor.switch(alt, Action::Replace); + } else { + cx.editor.set_error("no last accessed buffer") + } } -fn goto_reference(cx: &mut Context) { +fn goto_last_modification(cx: &mut Context) { let (view, doc) = current!(cx.editor); - let language_server = match doc.language_server() { - Some(language_server) => language_server, - None => return, - }; + let pos = doc.history.get_mut().last_edit_pos(); + let text = doc.text().slice(..); + if let Some(pos) = pos { + let selection = doc + .selection(view.id) + .clone() + .transform(|range| range.put_cursor(text, pos, doc.mode == Mode::Select)); + doc.set_selection(view.id, selection); + } +} - let offset_encoding = language_server.offset_encoding(); +fn goto_last_modified_file(cx: &mut Context) { + let view = view!(cx.editor); + let alternate_file = view + .last_modified_docs + .into_iter() + .flatten() + .find(|&id| id != view.doc); + if let Some(alt) = alternate_file { + cx.editor.switch(alt, Action::Replace); + } else { + cx.editor.set_error("no last modified buffer") + } +} - let pos = pos_to_lsp_pos( - doc.text(), - doc.selection(view.id) - .primary() - .cursor(doc.text().slice(..)), - offset_encoding, - ); +fn select_mode(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + + // Make sure end-of-document selections are also 1-width. + // (With the exception of being in an empty document, of course.) + let selection = doc.selection(view.id).clone().transform(|range| { + if range.is_empty() && range.head == text.len_chars() { + Range::new( + graphemes::prev_grapheme_boundary(text, range.anchor), + range.head, + ) + } else { + range + } + }); + doc.set_selection(view.id, selection); - let future = language_server.goto_reference(doc.identifier(), pos, None); + doc_mut!(cx.editor).mode = Mode::Select; +} - cx.callback( - future, - move |editor: &mut Editor, - compositor: &mut Compositor, - items: Option>| { - goto_impl( - editor, - compositor, - items.unwrap_or_default(), - offset_encoding, - ); - }, - ); +fn exit_select_mode(cx: &mut Context) { + let doc = doc_mut!(cx.editor); + if doc.mode == Mode::Select { + doc.mode = Mode::Normal; + } } fn goto_pos(editor: &mut Editor, pos: usize) { @@ -4616,46 +2535,6 @@ fn goto_prev_diag(cx: &mut Context) { goto_pos(editor, pos); } -fn signature_help(cx: &mut Context) { - let (view, doc) = current!(cx.editor); - - let language_server = match doc.language_server() { - Some(language_server) => language_server, - None => return, - }; - - let pos = pos_to_lsp_pos( - doc.text(), - doc.selection(view.id) - .primary() - .cursor(doc.text().slice(..)), - language_server.offset_encoding(), - ); - - let future = language_server.text_document_signature_help(doc.identifier(), pos, None); - - cx.callback( - future, - move |_editor: &mut Editor, - _compositor: &mut Compositor, - response: Option| { - if let Some(signature_help) = response { - log::info!("{:?}", signature_help); - // signatures - // active_signature - // active_parameter - // render as: - - // signature - // ---------- - // doc - - // with active param highlighted - } - }, - ); -} - pub mod insert { use super::*; pub type Hook = fn(&Rope, &Selection, char) -> Option; @@ -4664,6 +2543,7 @@ pub mod insert { // It trigger completion when idle timer reaches deadline // Only trigger completion if the word under cursor is longer than n characters pub fn idle_completion(cx: &mut Context) { + let config = cx.editor.config(); let (view, doc) = current!(cx.editor); let text = doc.text().slice(..); let cursor = doc.selection(view.id).primary().cursor(text); @@ -4671,7 +2551,7 @@ pub mod insert { use helix_core::chars::char_is_word; let mut iter = text.chars_at(cursor); iter.reverse(); - for _ in 0..cx.editor.config.completion_trigger_len { + for _ in 0..config.completion_trigger_len { match iter.next() { Some(c) if char_is_word(c) => {} _ => return, @@ -4681,6 +2561,7 @@ pub mod insert { } fn language_server_completion(cx: &mut Context, ch: char) { + use helix_lsp::lsp; // if ch matches completion char, trigger completion let doc = doc_mut!(cx.editor); let language_server = match doc.language_server() { @@ -4704,6 +2585,7 @@ pub mod insert { } fn signature_help(cx: &mut Context, ch: char) { + use helix_lsp::lsp; // if ch matches signature_help char, trigger let doc = doc_mut!(cx.editor); let language_server = match doc.language_server() { @@ -4760,22 +2642,19 @@ pub mod insert { use helix_core::auto_pairs; pub fn insert_char(cx: &mut Context, c: char) { - let (view, doc) = current!(cx.editor); - - let hooks: &[Hook] = match cx.editor.config.auto_pairs { - true => &[auto_pairs::hook, insert], - false => &[insert], - }; - + let (view, doc) = current_ref!(cx.editor); let text = doc.text(); let selection = doc.selection(view.id); + let auto_pairs = doc.auto_pairs(cx.editor); - // run through insert hooks, stopping on the first one that returns Some(t) - for hook in hooks { - if let Some(transaction) = hook(text, selection, c) { - doc.apply(&transaction, view.id); - break; - } + let transaction = auto_pairs + .as_ref() + .and_then(|ap| auto_pairs::hook(text, selection, c, ap)) + .or_else(|| insert(text, selection, c)); + + let (view, doc) = current!(cx.editor); + if let Some(t) = transaction { + doc.apply(&t, view.id); } // TODO: need a post insert hook too for certain triggers (autocomplete, signature help, etc) @@ -4802,7 +2681,7 @@ pub mod insert { } pub fn insert_newline(cx: &mut Context) { - let (view, doc) = current!(cx.editor); + let (view, doc) = current_ref!(cx.editor); let text = doc.text().slice(..); let contents = doc.text(); @@ -4823,23 +2702,28 @@ pub mod insert { let curr = contents.get_char(pos).unwrap_or(' '); let current_line = text.char_to_line(pos); - let indent_level = indent::suggested_indent_for_pos( + let indent = indent::indent_for_newline( doc.language_config(), doc.syntax(), + &doc.indent_style, + doc.tab_width(), text, + current_line, pos, current_line, - true, - ) - .unwrap_or_else(|| { - indent::indent_level_for_line(text.line(current_line), doc.tab_width()) - }); - - let indent = doc.indent_unit().repeat(indent_level); + ); let mut text = String::new(); - // If we are between pairs (such as brackets), we want to insert an additional line which is indented one level more and place the cursor there - let new_head_pos = if helix_core::auto_pairs::PAIRS.contains(&(prev, curr)) { - let inner_indent = doc.indent_unit().repeat(indent_level + 1); + // If we are between pairs (such as brackets), we want to + // insert an additional line which is indented one level + // more and place the cursor there + let on_auto_pair = doc + .auto_pairs(cx.editor) + .and_then(|pairs| pairs.get(prev)) + .and_then(|pair| if pair.close == curr { Some(pair) } else { None }) + .is_some(); + + let new_head_pos = if on_auto_pair { + let inner_indent = indent.clone() + doc.indent_style.as_str(); text.reserve_exact(2 + indent.len() + inner_indent.len()); text.push_str(doc.line_ending.as_str()); text.push_str(&inner_indent); @@ -4865,6 +2749,7 @@ pub mod insert { transaction = transaction.with_selection(Selection::new(ranges, selection.primary_index())); + let (view, doc) = current!(cx.editor); doc.apply(&transaction, view.id); } @@ -4979,9 +2864,6 @@ pub mod insert { // Undo / Redo -// TODO: each command could simply return a Option, then the higher level handles -// storing it? - fn undo(cx: &mut Context) { let count = cx.count(); let (view, doc) = current!(cx.editor); @@ -5131,6 +3013,7 @@ fn yank_main_selection_to_primary_clipboard(cx: &mut Context) { enum Paste { Before, After, + Cursor, } fn paste_impl( @@ -5177,6 +3060,8 @@ fn paste_impl( (Paste::Before, false) => range.from(), // paste append (Paste::After, false) => range.to(), + // paste at cursor + (Paste::Cursor, _) => range.cursor(text.slice(..)), }; (pos, pos, values.next()) }); @@ -5309,7 +3194,7 @@ fn replace_selections_with_primary_clipboard(cx: &mut Context) { let _ = replace_selections_with_clipboard_impl(cx.editor, ClipboardType::Selection, cx.count()); } -fn paste_after(cx: &mut Context) { +fn paste(cx: &mut Context, pos: Paste) { let count = cx.count(); let reg_name = cx.register.unwrap_or('"'); let (view, doc) = current!(cx.editor); @@ -5317,24 +3202,18 @@ fn paste_after(cx: &mut Context) { if let Some(transaction) = registers .read(reg_name) - .and_then(|values| paste_impl(values, doc, view, Paste::After, count)) + .and_then(|values| paste_impl(values, doc, view, pos, count)) { doc.apply(&transaction, view.id); } } -fn paste_before(cx: &mut Context) { - let count = cx.count(); - let reg_name = cx.register.unwrap_or('"'); - let (view, doc) = current!(cx.editor); - let registers = &mut cx.editor.registers; +fn paste_after(cx: &mut Context) { + paste(cx, Paste::After) +} - if let Some(transaction) = registers - .read(reg_name) - .and_then(|values| paste_impl(values, doc, view, Paste::Before, count)) - { - doc.apply(&transaction, view.id); - } +fn paste_before(cx: &mut Context) { + paste(cx, Paste::Before) } fn get_lines(doc: &Document, view_id: ViewId) -> Vec { @@ -5363,9 +3242,13 @@ fn indent(cx: &mut Context) { let transaction = Transaction::change( doc.text(), - lines.into_iter().map(|line| { + lines.into_iter().filter_map(|line| { + let is_blank = doc.text().line(line).chunks().all(|s| s.trim().is_empty()); + if is_blank { + return None; + } let pos = doc.text().line_to_char(line); - (pos, pos, Some(indent.clone())) + Some((pos, pos, Some(indent.clone()))) }), ); doc.apply(&transaction, view.id); @@ -5411,6 +3294,8 @@ fn unindent(cx: &mut Context) { } fn format_selections(cx: &mut Context) { + use helix_lsp::{lsp, util::range_to_lsp_range}; + let (view, doc) = current!(cx.editor); // via lsp if available @@ -5501,11 +3386,11 @@ fn join_selections(cx: &mut Context) { fn keep_or_remove_selections_impl(cx: &mut Context, remove: bool) { // keep or remove selections matching regex let reg = cx.register.unwrap_or('/'); - let prompt = ui::regex_prompt( + ui::regex_prompt( cx, - if !remove { "keep:" } else { "remove:" }.into(), + if remove { "remove:" } else { "keep:" }.into(), Some(reg), - |_input: &str| Vec::new(), + ui::completers::none, move |view, doc, regex, event| { if event != PromptEvent::Update { return; @@ -5518,9 +3403,7 @@ fn keep_or_remove_selections_impl(cx: &mut Context, remove: bool) { doc.set_selection(view.id, selection); } }, - ); - - cx.push_layer(Box::new(prompt)); + ) } fn keep_selections(cx: &mut Context) { @@ -5555,41 +3438,7 @@ fn remove_primary_selection(cx: &mut Context) { } pub fn completion(cx: &mut Context) { - // trigger on trigger char, or if user calls it - // (or on word char typing??) - // after it's triggered, if response marked is_incomplete, update on every subsequent keypress - // - // lsp calls are done via a callback: it sends a request and doesn't block. - // when we get the response similarly to notification, trigger a call to the completion popup - // - // language_server.completion(params, |cx: &mut Context, _meta, response| { - // // called at response time - // // compositor, lookup completion layer - // // downcast dyn Component to Completion component - // // emit response to completion (completion.complete/handle(response)) - // }) - // - // typing after prompt opens: usually start offset is tracked and everything between - // start_offset..cursor is replaced. For our purposes we could keep the start state (doc, - // selection) and revert to them before applying. This needs to properly reset changes/history - // though... - // - // company-mode does this by matching the prefix of the completion and removing it. - - // ignore isIncomplete for now - // keep state while typing - // the behavior should be, filter the menu based on input - // if items returns empty at any point, remove the popup - // if backspace past initial offset point, remove the popup - // - // debounce requests! - // - // need an idle timeout thing. - // https://github.com/company-mode/company-mode/blob/master/company.el#L620-L622 - // - // "The idle delay in seconds until completion starts automatically. - // The prefix still has to satisfy `company-minimum-prefix-length' before that - // happens. The value of nil means no idle completion." + use helix_lsp::{lsp, util::pos_to_lsp_pos}; let (view, doc) = current!(cx.editor); @@ -5620,9 +3469,7 @@ pub fn completion(cx: &mut Context) { cx.callback( future, - move |editor: &mut Editor, - compositor: &mut Compositor, - response: Option| { + move |editor, compositor, response: Option| { let doc = doc!(editor); if doc.mode() != Mode::Insert { // we're not in insert mode anymore @@ -5669,66 +3516,6 @@ pub fn completion(cx: &mut Context) { ); } -fn hover(cx: &mut Context) { - let (view, doc) = current!(cx.editor); - - let language_server = match doc.language_server() { - Some(language_server) => language_server, - None => return, - }; - - // TODO: factor out a doc.position_identifier() that returns lsp::TextDocumentPositionIdentifier - - let pos = pos_to_lsp_pos( - doc.text(), - doc.selection(view.id) - .primary() - .cursor(doc.text().slice(..)), - language_server.offset_encoding(), - ); - - let future = language_server.text_document_hover(doc.identifier(), pos, None); - - cx.callback( - future, - move |editor: &mut Editor, compositor: &mut Compositor, response: Option| { - if let Some(hover) = response { - // hover.contents / .range <- used for visualizing - - fn marked_string_to_markdown(contents: lsp::MarkedString) -> String { - match contents { - lsp::MarkedString::String(contents) => contents, - lsp::MarkedString::LanguageString(string) => { - if string.language == "markdown" { - string.value - } else { - format!("```{}\n{}\n```", string.language, string.value) - } - } - } - } - - let contents = match hover.contents { - lsp::HoverContents::Scalar(contents) => marked_string_to_markdown(contents), - lsp::HoverContents::Array(contents) => contents - .into_iter() - .map(marked_string_to_markdown) - .collect::>() - .join("\n\n"), - lsp::HoverContents::Markup(contents) => contents.value, - }; - - // skip if contents empty - - let contents = - ui::Markdown::new(contents, editor.syn_loader.clone()).style_group("hover"); - let popup = Popup::new("hover", contents); - compositor.replace_or_push("hover", Box::new(popup)); - } - }, - ); -} - // comments fn toggle_comments(cx: &mut Context) { let (view, doc) = current!(cx.editor); @@ -5886,7 +3673,7 @@ fn match_brackets(cx: &mut Context) { let text = doc.text().slice(..); let selection = doc.selection(view.id).clone().transform(|range| { if let Some(pos) = - match_brackets::find_matching_bracket_fuzzy(syntax, doc.text(), range.anchor) + match_brackets::find_matching_bracket_fuzzy(syntax, doc.text(), range.cursor(text)) { range.put_cursor(text, pos, doc.mode == Mode::Select) } else { @@ -5975,13 +3762,21 @@ fn hsplit(cx: &mut Context) { split(cx, Action::HorizontalSplit); } +fn hsplit_new(cx: &mut Context) { + cx.editor.new_file(Action::HorizontalSplit); +} + fn vsplit(cx: &mut Context) { split(cx, Action::VerticalSplit); } +fn vsplit_new(cx: &mut Context) { + cx.editor.new_file(Action::VerticalSplit); +} + fn wclose(cx: &mut Context) { if cx.editor.tree.views().count() == 1 { - if let Err(err) = cmd::buffers_remaining_impl(cx.editor) { + if let Err(err) = typed::buffers_remaining_impl(cx.editor) { cx.editor.set_error(err.to_string()); return; } @@ -6016,10 +3811,12 @@ fn select_register(cx: &mut Context) { } fn insert_register(cx: &mut Context) { + cx.editor.autoinfo = Some(Info::from_registers(&cx.editor.registers)); cx.on_next_key(move |cx, event| { if let Some(ch) = event.char() { - cx.editor.selected_register = Some(ch); - paste_before(cx); + cx.editor.autoinfo = None; + cx.register = Some(ch); + paste(cx, Paste::Cursor); } }) } @@ -6104,6 +3901,14 @@ fn goto_prev_parameter(cx: &mut Context) { goto_ts_object_impl(cx, "parameter", Direction::Backward) } +fn goto_next_comment(cx: &mut Context) { + goto_ts_object_impl(cx, "comment", Direction::Forward) +} + +fn goto_prev_comment(cx: &mut Context) { + goto_ts_object_impl(cx, "comment", Direction::Backward) +} + fn select_textobject_around(cx: &mut Context) { select_textobject(cx, textobject::TextObject::Around); } @@ -6114,7 +3919,10 @@ fn select_textobject_inner(cx: &mut Context) { fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) { let count = cx.count(); + cx.on_next_key(move |cx, event| { + cx.editor.autoinfo = None; + cx.editor.pseudo_pending = None; if let Some(ch) = event.char() { let textobject = move |editor: &mut Editor| { let (view, doc) = current!(editor); @@ -6142,7 +3950,8 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) { 'W' => textobject::textobject_word(text, range, objtype, count, true), 'c' => textobject_treesitter("class", range), 'f' => textobject_treesitter("function", range), - 'p' => textobject_treesitter("parameter", range), + 'a' => textobject_treesitter("parameter", range), + 'o' => textobject_treesitter("comment", range), 'm' => { let ch = text.char(range.cursor(text)); if !ch.is_ascii_alphanumeric() { @@ -6163,81 +3972,121 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) { textobject(cx.editor); cx.editor.last_motion = Some(Motion(Box::new(textobject))); } - }) + }); + + if let Some((title, abbrev)) = match objtype { + textobject::TextObject::Inside => Some(("Match inside", "mi")), + textobject::TextObject::Around => Some(("Match around", "ma")), + _ => return, + } { + let help_text = [ + ("w", "Word"), + ("W", "WORD"), + ("c", "Class (tree-sitter)"), + ("f", "Function (tree-sitter)"), + ("a", "Argument/parameter (tree-sitter)"), + ("o", "Comment (tree-sitter)"), + ("m", "Matching delimiter under cursor"), + (" ", "... or any character acting as a pair"), + ]; + + cx.editor.autoinfo = Some(Info::new( + title, + help_text + .into_iter() + .map(|(col1, col2)| (col1.to_string(), col2.to_string())) + .collect(), + )); + cx.editor.pseudo_pending = Some(abbrev.to_string()); + }; } fn surround_add(cx: &mut Context) { cx.on_next_key(move |cx, event| { - if let Some(ch) = event.char() { - let (view, doc) = current!(cx.editor); - let selection = doc.selection(view.id); - let (open, close) = surround::get_pair(ch); - - let mut changes = Vec::with_capacity(selection.len() * 2); - for range in selection.iter() { - let mut o = Tendril::new(); - o.push(open); - let mut c = Tendril::new(); - c.push(close); - changes.push((range.from(), range.from(), Some(o))); - changes.push((range.to(), range.to(), Some(c))); - } - - let transaction = Transaction::change(doc.text(), changes.into_iter()); - doc.apply(&transaction, view.id); + let ch = match event.char() { + Some(ch) => ch, + None => return, + }; + let (view, doc) = current!(cx.editor); + let selection = doc.selection(view.id); + let (open, close) = surround::get_pair(ch); + + let mut changes = Vec::with_capacity(selection.len() * 2); + for range in selection.iter() { + let mut o = Tendril::new(); + o.push(open); + let mut c = Tendril::new(); + c.push(close); + changes.push((range.from(), range.from(), Some(o))); + changes.push((range.to(), range.to(), Some(c))); } + + let transaction = Transaction::change(doc.text(), changes.into_iter()); + doc.apply(&transaction, view.id); }) } fn surround_replace(cx: &mut Context) { let count = cx.count(); cx.on_next_key(move |cx, event| { - if let Some(from) = event.char() { - cx.on_next_key(move |cx, event| { - if let Some(to) = event.char() { - let (view, doc) = current!(cx.editor); - let text = doc.text().slice(..); - let selection = doc.selection(view.id); - - let change_pos = match surround::get_surround_pos(text, selection, from, count) - { - Some(c) => c, - None => return, - }; + let from = match event.char() { + Some(from) => from, + None => return, + }; + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + let selection = doc.selection(view.id); - let (open, close) = surround::get_pair(to); - let transaction = Transaction::change( - doc.text(), - change_pos.iter().enumerate().map(|(i, &pos)| { - let mut t = Tendril::new(); - t.push(if i % 2 == 0 { open } else { close }); - (pos, pos + 1, Some(t)) - }), - ); - doc.apply(&transaction, view.id); - } - }); - } + let change_pos = match surround::get_surround_pos(text, selection, from, count) { + Ok(c) => c, + Err(err) => { + cx.editor.set_error(err.to_string()); + return; + } + }; + + cx.on_next_key(move |cx, event| { + let (view, doc) = current!(cx.editor); + let to = match event.char() { + Some(to) => to, + None => return, + }; + let (open, close) = surround::get_pair(to); + let transaction = Transaction::change( + doc.text(), + change_pos.iter().enumerate().map(|(i, &pos)| { + let mut t = Tendril::new(); + t.push(if i % 2 == 0 { open } else { close }); + (pos, pos + 1, Some(t)) + }), + ); + doc.apply(&transaction, view.id); + }); }) } fn surround_delete(cx: &mut Context) { let count = cx.count(); cx.on_next_key(move |cx, event| { - if let Some(ch) = event.char() { - let (view, doc) = current!(cx.editor); - let text = doc.text().slice(..); - let selection = doc.selection(view.id); + let ch = match event.char() { + Some(ch) => ch, + None => return, + }; + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + let selection = doc.selection(view.id); - let change_pos = match surround::get_surround_pos(text, selection, ch, count) { - Some(c) => c, - None => return, - }; + let change_pos = match surround::get_surround_pos(text, selection, ch, count) { + Ok(c) => c, + Err(err) => { + cx.editor.set_error(err.to_string()); + return; + } + }; - let transaction = - Transaction::change(doc.text(), change_pos.into_iter().map(|p| (p, p + 1, None))); - doc.apply(&transaction, view.id); - } + let transaction = + Transaction::change(doc.text(), change_pos.into_iter().map(|p| (p, p + 1, None))); + doc.apply(&transaction, view.id); }) } @@ -6266,12 +4115,13 @@ fn shell_append_output(cx: &mut Context) { } fn shell_keep_pipe(cx: &mut Context) { - let prompt = Prompt::new( + ui::prompt( + cx, "keep-pipe:".into(), Some('|'), - |_input: &str| Vec::new(), - move |cx: &mut compositor::Context, input: &str, event: PromptEvent| { - let shell = &cx.editor.config.shell; + ui::completers::none, + move |cx, input: &str, event: PromptEvent| { + let shell = &cx.editor.config().shell; if event != PromptEvent::Validate { return; } @@ -6314,8 +4164,6 @@ fn shell_keep_pipe(cx: &mut Context) { doc.set_selection(view.id, Selection::new(ranges, index)); }, ); - - cx.push_layer(Box::new(prompt)); } fn shell_impl( @@ -6362,12 +4210,15 @@ fn shell(cx: &mut Context, prompt: Cow<'static, str>, behavior: ShellBehavior) { ShellBehavior::Replace | ShellBehavior::Ignore => true, ShellBehavior::Insert | ShellBehavior::Append => false, }; - let prompt = Prompt::new( + + ui::prompt( + cx, prompt, Some('|'), - |_input: &str| Vec::new(), - move |cx: &mut compositor::Context, input: &str, event: PromptEvent| { - let shell = &cx.editor.config.shell; + ui::completers::none, + move |cx, input: &str, event: PromptEvent| { + let config = cx.editor.config(); + let shell = &config.shell; if event != PromptEvent::Validate { return; } @@ -6412,11 +4263,9 @@ fn shell(cx: &mut Context, prompt: Cow<'static, str>, behavior: ShellBehavior) { // after replace cursor may be out of bounds, do this to // make sure cursor is in view and update scroll as well - view.ensure_cursor_in_view(doc, cx.editor.config.scrolloff); + view.ensure_cursor_in_view(doc, config.scrolloff); }, ); - - cx.push_layer(Box::new(prompt)); } fn suspend(_cx: &mut Context) { @@ -6457,43 +4306,6 @@ fn add_newline_impl(cx: &mut Context, open: Open) { doc.apply(&transaction, view.id); } -fn rename_symbol(cx: &mut Context) { - let prompt = Prompt::new( - "rename-to:".into(), - None, - |_input: &str| Vec::new(), - move |cx: &mut compositor::Context, input: &str, event: PromptEvent| { - if event != PromptEvent::Validate { - return; - } - - log::debug!("renaming to: {:?}", input); - - let (view, doc) = current!(cx.editor); - let language_server = match doc.language_server() { - Some(language_server) => language_server, - None => return, - }; - - let offset_encoding = language_server.offset_encoding(); - - let pos = pos_to_lsp_pos( - doc.text(), - doc.selection(view.id) - .primary() - .cursor(doc.text().slice(..)), - offset_encoding, - ); - - let task = language_server.rename_symbol(doc.identifier(), pos, input.to_string()); - let edits = block_on(task).unwrap_or_default(); - log::debug!("Edits from LSP: {:?}", edits); - apply_workspace_edit(cx.editor, offset_encoding, &edits); - }, - ); - cx.push_layer(Box::new(prompt)); -} - /// Increment object under cursor by count. fn increment(cx: &mut Context) { increment_impl(cx, cx.count() as i64); @@ -6599,13 +4411,11 @@ fn replay_macro(cx: &mut Context) { }; let count = cx.count(); - cx.callback = Some(Box::new( - move |compositor: &mut Compositor, cx: &mut compositor::Context| { - for _ in 0..count { - for &key in keys.iter() { - compositor.handle_event(crossterm::event::Event::Key(key.into()), cx); - } + cx.callback = Some(Box::new(move |compositor, cx| { + for _ in 0..count { + for &key in keys.iter() { + compositor.handle_event(crossterm::event::Event::Key(key.into()), cx); } - }, - )); + } + })); } diff --git a/helix-term/src/commands/dap.rs b/helix-term/src/commands/dap.rs index 9da2715f4..b897b2d58 100644 --- a/helix-term/src/commands/dap.rs +++ b/helix-term/src/commands/dap.rs @@ -1,14 +1,11 @@ -use super::{align_view, Align, Context, Editor}; +use super::{Context, Editor}; use crate::{ compositor::{self, Compositor}, job::{Callback, Jobs}, ui::{self, overlay::overlayed, FilePicker, Picker, Popup, Prompt, PromptEvent, Text}, }; -use helix_core::{ - syntax::{DebugArgumentValue, DebugConfigCompletion}, - Selection, -}; -use helix_dap::{self as dap, Client, ThreadId}; +use helix_core::syntax::{DebugArgumentValue, DebugConfigCompletion}; +use helix_dap::{self as dap, Client}; use helix_lsp::block_on; use helix_view::editor::Breakpoint; @@ -21,79 +18,7 @@ use std::path::PathBuf; use anyhow::{anyhow, bail}; -#[macro_export] -macro_rules! debugger { - ($editor:expr) => {{ - match &mut $editor.debugger { - Some(debugger) => debugger, - None => return, - } - }}; -} - -// general utils: -pub fn dap_pos_to_pos(doc: &helix_core::Rope, line: usize, column: usize) -> Option { - // 1-indexing to 0 indexing - let line = doc.try_line_to_char(line - 1).ok()?; - let pos = line + column.saturating_sub(1); - // TODO: this is probably utf-16 offsets - Some(pos) -} - -pub async fn select_thread_id(editor: &mut Editor, thread_id: ThreadId, force: bool) { - let debugger = debugger!(editor); - - if !force && debugger.thread_id.is_some() { - return; - } - - debugger.thread_id = Some(thread_id); - fetch_stack_trace(debugger, thread_id).await; - - let frame = debugger.stack_frames[&thread_id].get(0).cloned(); - if let Some(frame) = &frame { - jump_to_stack_frame(editor, frame); - } -} - -pub async fn fetch_stack_trace(debugger: &mut Client, thread_id: ThreadId) { - let (frames, _) = match debugger.stack_trace(thread_id).await { - Ok(frames) => frames, - Err(_) => return, - }; - debugger.stack_frames.insert(thread_id, frames); - debugger.active_frame = Some(0); -} - -pub fn jump_to_stack_frame(editor: &mut Editor, frame: &helix_dap::StackFrame) { - let path = if let Some(helix_dap::Source { - path: Some(ref path), - .. - }) = frame.source - { - path.clone() - } else { - return; - }; - - if let Err(e) = editor.open(path, helix_view::editor::Action::Replace) { - editor.set_error(format!("Unable to jump to stack frame: {}", e)); - return; - } - - let (view, doc) = current!(editor); - - let text_end = doc.text().len_chars().saturating_sub(1); - let start = dap_pos_to_pos(doc.text(), frame.line, frame.column).unwrap_or(0); - let end = frame - .end_line - .and_then(|end_line| dap_pos_to_pos(doc.text(), end_line, frame.end_column.unwrap_or(0))) - .unwrap_or(start); - - let selection = Selection::single(start.min(text_end), end.min(text_end)); - doc.set_selection(view.id, selection); - align_view(doc, view, Align::Center); -} +use helix_view::handlers::dap::{breakpoints_changed, jump_to_stack_frame, select_thread_id}; fn thread_picker( cx: &mut Context, @@ -105,9 +30,7 @@ fn thread_picker( dap_callback( cx.jobs, future, - move |editor: &mut Editor, - compositor: &mut Compositor, - response: dap::requests::ThreadsResponse| { + move |editor, compositor, response: dap::requests::ThreadsResponse| { let threads = response.threads; if threads.len() == 1 { callback_fn(editor, &threads[0]); @@ -190,7 +113,7 @@ pub fn dap_start_impl( let config = doc .language_config() .and_then(|config| config.debugger.as_ref()) - .ok_or(anyhow!("No debug adapter available for language"))?; + .ok_or_else(|| anyhow!("No debug adapter available for language"))?; let result = match socket { Some(socket) => block_on(Client::tcp(socket, 0)), @@ -220,7 +143,7 @@ pub fn dap_start_impl( Some(name) => config.templates.iter().find(|t| t.name == name), None => config.templates.get(0), } - .ok_or(anyhow!("No debug config with given name"))?; + .ok_or_else(|| anyhow!("No debug config with given name"))?; let mut args: HashMap<&str, Value> = HashMap::new(); @@ -325,11 +248,10 @@ pub fn dap_launch(cx: &mut Context) { let completions = template.completion.clone(); let name = template.name.clone(); let callback = Box::pin(async move { - let call: Callback = - Box::new(move |_editor: &mut Editor, compositor: &mut Compositor| { - let prompt = debug_parameter_prompt(completions, name, Vec::new()); - compositor.push(Box::new(prompt)); - }); + let call: Callback = Box::new(move |_editor, compositor| { + let prompt = debug_parameter_prompt(completions, name, Vec::new()); + compositor.push(Box::new(prompt)); + }); Ok(call) }); cx.jobs.callback(callback); @@ -361,8 +283,9 @@ fn debug_parameter_prompt( let completer = match field_type { "filename" => ui::completers::filename, "directory" => ui::completers::directory, - _ => |_input: &str| Vec::new(), + _ => ui::completers::none, }; + Prompt::new( format!("{}: ", name).into(), None, @@ -383,21 +306,20 @@ fn debug_parameter_prompt( let config_name = config_name.clone(); let params = params.clone(); let callback = Box::pin(async move { - let call: Callback = - Box::new(move |_editor: &mut Editor, compositor: &mut Compositor| { - let prompt = debug_parameter_prompt(completions, config_name, params); - compositor.push(Box::new(prompt)); - }); + let call: Callback = Box::new(move |_editor, compositor| { + let prompt = debug_parameter_prompt(completions, config_name, params); + compositor.push(Box::new(prompt)); + }); Ok(call) }); cx.jobs.callback(callback); - } else if let Err(e) = dap_start_impl( + } else if let Err(err) = dap_start_impl( cx, Some(&config_name), None, Some(params.iter().map(|x| x.into()).collect()), ) { - cx.editor.set_error(e.to_string()); + cx.editor.set_error(err.to_string()); } }, ) @@ -418,63 +340,6 @@ pub fn dap_toggle_breakpoint(cx: &mut Context) { dap_toggle_breakpoint_impl(cx, path, line); } -pub fn breakpoints_changed( - debugger: &mut dap::Client, - path: PathBuf, - breakpoints: &mut [Breakpoint], -) -> Result<(), anyhow::Error> { - // TODO: handle capabilities correctly again, by filterin breakpoints when emitting - // if breakpoint.condition.is_some() - // && !debugger - // .caps - // .as_ref() - // .unwrap() - // .supports_conditional_breakpoints - // .unwrap_or_default() - // { - // bail!( - // "Can't edit breakpoint: debugger does not support conditional breakpoints" - // ) - // } - // if breakpoint.log_message.is_some() - // && !debugger - // .caps - // .as_ref() - // .unwrap() - // .supports_log_points - // .unwrap_or_default() - // { - // bail!("Can't edit breakpoint: debugger does not support logpoints") - // } - let source_breakpoints = breakpoints - .iter() - .map(|breakpoint| helix_dap::SourceBreakpoint { - line: breakpoint.line + 1, // convert from 0-indexing to 1-indexing (TODO: could set debugger to 0-indexing on init) - ..Default::default() - }) - .collect::>(); - - let request = debugger.set_breakpoints(path, source_breakpoints); - match block_on(request) { - Ok(Some(dap_breakpoints)) => { - for (breakpoint, dap_breakpoint) in breakpoints.iter_mut().zip(dap_breakpoints) { - breakpoint.id = dap_breakpoint.id; - breakpoint.verified = dap_breakpoint.verified; - breakpoint.message = dap_breakpoint.message; - // TODO: handle breakpoint.message - // TODO: verify source matches - breakpoint.line = dap_breakpoint.line.unwrap_or(0).saturating_sub(1); // convert to 0-indexing - // TODO: no unwrap - breakpoint.column = dap_breakpoint.column; - // TODO: verify end_linef/col instruction reference, offset - } - } - Err(e) => anyhow::bail!("Failed to set breakpoints: {}", e), - _ => {} - }; - Ok(()) -} - pub fn dap_toggle_breakpoint_impl(cx: &mut Context, path: PathBuf, line: usize) { // TODO: need to map breakpoints over edits and update them? // we shouldn't really allow editing while debug is running though @@ -691,37 +556,35 @@ pub fn dap_edit_condition(cx: &mut Context) { None => return, }; let callback = Box::pin(async move { - let call: Callback = - Box::new(move |_editor: &mut Editor, compositor: &mut Compositor| { - let mut prompt = Prompt::new( - "condition:".into(), - None, - |_input: &str| Vec::new(), - move |cx, input: &str, event: PromptEvent| { - if event != PromptEvent::Validate { - return; - } - - let breakpoints = &mut cx.editor.breakpoints.get_mut(&path).unwrap(); - breakpoints[pos].condition = match input { - "" => None, - input => Some(input.to_owned()), - }; - - let debugger = debugger!(cx.editor); - - if let Err(e) = breakpoints_changed(debugger, path.clone(), breakpoints) - { - cx.editor - .set_error(format!("Failed to set breakpoints: {}", e)); - } - }, - ); - if let Some(condition) = breakpoint.condition { - prompt.insert_str(&condition) - } - compositor.push(Box::new(prompt)); - }); + let call: Callback = Box::new(move |_editor, compositor| { + let mut prompt = Prompt::new( + "condition:".into(), + None, + ui::completers::none, + move |cx, input: &str, event: PromptEvent| { + if event != PromptEvent::Validate { + return; + } + + let breakpoints = &mut cx.editor.breakpoints.get_mut(&path).unwrap(); + breakpoints[pos].condition = match input { + "" => None, + input => Some(input.to_owned()), + }; + + let debugger = debugger!(cx.editor); + + if let Err(e) = breakpoints_changed(debugger, path.clone(), breakpoints) { + cx.editor + .set_error(format!("Failed to set breakpoints: {}", e)); + } + }, + ); + if let Some(condition) = breakpoint.condition { + prompt.insert_str(&condition) + } + compositor.push(Box::new(prompt)); + }); Ok(call) }); cx.jobs.callback(callback); @@ -735,36 +598,34 @@ pub fn dap_edit_log(cx: &mut Context) { None => return, }; let callback = Box::pin(async move { - let call: Callback = - Box::new(move |_editor: &mut Editor, compositor: &mut Compositor| { - let mut prompt = Prompt::new( - "log-message:".into(), - None, - |_input: &str| Vec::new(), - move |cx, input: &str, event: PromptEvent| { - if event != PromptEvent::Validate { - return; - } - - let breakpoints = &mut cx.editor.breakpoints.get_mut(&path).unwrap(); - breakpoints[pos].log_message = match input { - "" => None, - input => Some(input.to_owned()), - }; - - let debugger = debugger!(cx.editor); - if let Err(e) = breakpoints_changed(debugger, path.clone(), breakpoints) - { - cx.editor - .set_error(format!("Failed to set breakpoints: {}", e)); - } - }, - ); - if let Some(log_message) = breakpoint.log_message { - prompt.insert_str(&log_message); - } - compositor.push(Box::new(prompt)); - }); + let call: Callback = Box::new(move |_editor, compositor| { + let mut prompt = Prompt::new( + "log-message:".into(), + None, + ui::completers::none, + move |cx, input: &str, event: PromptEvent| { + if event != PromptEvent::Validate { + return; + } + + let breakpoints = &mut cx.editor.breakpoints.get_mut(&path).unwrap(); + breakpoints[pos].log_message = match input { + "" => None, + input => Some(input.to_owned()), + }; + + let debugger = debugger!(cx.editor); + if let Err(e) = breakpoints_changed(debugger, path.clone(), breakpoints) { + cx.editor + .set_error(format!("Failed to set breakpoints: {}", e)); + } + }, + ); + if let Some(log_message) = breakpoint.log_message { + prompt.insert_str(&log_message); + } + compositor.push(Box::new(prompt)); + }); Ok(call) }); cx.jobs.callback(callback); @@ -791,7 +652,7 @@ pub fn dap_switch_stack_frame(cx: &mut Context) { let picker = FilePicker::new( frames, - |frame| frame.name.clone().into(), // TODO: include thread_states in the label + |frame| frame.name.as_str().into(), // TODO: include thread_states in the label move |cx, frame, _action| { let debugger = debugger!(cx.editor); // TODO: this should be simpler to find diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs new file mode 100644 index 000000000..1db57ecf2 --- /dev/null +++ b/helix-term/src/commands/lsp.rs @@ -0,0 +1,667 @@ +use helix_lsp::{ + block_on, lsp, + util::{lsp_pos_to_pos, lsp_range_to_range, range_to_lsp_range}, + OffsetEncoding, +}; + +use super::{align_view, push_jump, Align, Context, Editor}; + +use helix_core::Selection; +use helix_view::editor::Action; + +use crate::{ + compositor::{self, Compositor}, + ui::{self, overlay::overlayed, FileLocation, FilePicker, Popup, PromptEvent}, +}; + +use std::borrow::Cow; + +#[macro_export] +macro_rules! language_server { + ($editor:expr, $doc:expr) => { + match $doc.language_server() { + Some(language_server) => language_server, + None => { + $editor.set_status("Language server not active for current buffer"); + return; + } + } + }; +} + +fn location_to_file_location(location: &lsp::Location) -> FileLocation { + let path = location.uri.to_file_path().unwrap(); + let line = Some(( + location.range.start.line as usize, + location.range.end.line as usize, + )); + (path, line) +} + +// TODO: share with symbol picker(symbol.location) +// TODO: need to use push_jump() before? +fn jump_to_location( + editor: &mut Editor, + location: &lsp::Location, + offset_encoding: OffsetEncoding, + action: Action, +) { + let path = location + .uri + .to_file_path() + .expect("unable to convert URI to filepath"); + let _id = editor.open(path, action).expect("editor.open failed"); + let (view, doc) = current!(editor); + let definition_pos = location.range.start; + // TODO: convert inside server + let new_pos = if let Some(new_pos) = lsp_pos_to_pos(doc.text(), definition_pos, offset_encoding) + { + new_pos + } else { + return; + }; + doc.set_selection(view.id, Selection::point(new_pos)); + align_view(doc, view, Align::Center); +} + +fn sym_picker( + symbols: Vec, + current_path: Option, + offset_encoding: OffsetEncoding, +) -> FilePicker { + // TODO: drop current_path comparison and instead use workspace: bool flag? + let current_path2 = current_path.clone(); + FilePicker::new( + symbols, + move |symbol| { + if current_path.as_ref() == Some(&symbol.location.uri) { + symbol.name.as_str().into() + } else { + let path = symbol.location.uri.to_file_path().unwrap(); + let relative_path = helix_core::path::get_relative_path(path.as_path()) + .to_string_lossy() + .into_owned(); + format!("{} ({})", &symbol.name, relative_path).into() + } + }, + move |cx, symbol, action| { + if current_path2.as_ref() == Some(&symbol.location.uri) { + push_jump(cx.editor); + } else { + let path = symbol.location.uri.to_file_path().unwrap(); + cx.editor.open(path, action).expect("editor.open failed"); + } + + let (view, doc) = current!(cx.editor); + + if let Some(range) = + lsp_range_to_range(doc.text(), symbol.location.range, offset_encoding) + { + // we flip the range so that the cursor sits on the start of the symbol + // (for example start of the function). + doc.set_selection(view.id, Selection::single(range.head, range.anchor)); + align_view(doc, view, Align::Center); + } + }, + move |_editor, symbol| Some(location_to_file_location(&symbol.location)), + ) + .truncate_start(false) +} + +pub fn symbol_picker(cx: &mut Context) { + fn nested_to_flat( + list: &mut Vec, + file: &lsp::TextDocumentIdentifier, + symbol: lsp::DocumentSymbol, + ) { + #[allow(deprecated)] + list.push(lsp::SymbolInformation { + name: symbol.name, + kind: symbol.kind, + tags: symbol.tags, + deprecated: symbol.deprecated, + location: lsp::Location::new(file.uri.clone(), symbol.selection_range), + container_name: None, + }); + for child in symbol.children.into_iter().flatten() { + nested_to_flat(list, file, child); + } + } + let doc = doc!(cx.editor); + + let language_server = language_server!(cx.editor, doc); + let current_url = doc.url(); + let offset_encoding = language_server.offset_encoding(); + + let future = language_server.document_symbols(doc.identifier()); + + cx.callback( + future, + move |editor, compositor, response: Option| { + if let Some(symbols) = response { + // lsp has two ways to represent symbols (flat/nested) + // convert the nested variant to flat, so that we have a homogeneous list + let symbols = match symbols { + lsp::DocumentSymbolResponse::Flat(symbols) => symbols, + lsp::DocumentSymbolResponse::Nested(symbols) => { + let doc = doc!(editor); + let mut flat_symbols = Vec::new(); + for symbol in symbols { + nested_to_flat(&mut flat_symbols, &doc.identifier(), symbol) + } + flat_symbols + } + }; + + let picker = sym_picker(symbols, current_url, offset_encoding); + compositor.push(Box::new(overlayed(picker))) + } + }, + ) +} + +pub fn workspace_symbol_picker(cx: &mut Context) { + let doc = doc!(cx.editor); + let current_url = doc.url(); + let language_server = language_server!(cx.editor, doc); + let offset_encoding = language_server.offset_encoding(); + let future = language_server.workspace_symbols("".to_string()); + + cx.callback( + future, + move |_editor, compositor, response: Option>| { + if let Some(symbols) = response { + let picker = sym_picker(symbols, current_url, offset_encoding); + compositor.push(Box::new(overlayed(picker))) + } + }, + ) +} + +impl ui::menu::Item for lsp::CodeActionOrCommand { + fn label(&self) -> &str { + match self { + lsp::CodeActionOrCommand::CodeAction(action) => action.title.as_str(), + lsp::CodeActionOrCommand::Command(command) => command.title.as_str(), + } + } +} + +pub fn code_action(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + + let language_server = language_server!(cx.editor, doc); + + let range = range_to_lsp_range( + doc.text(), + doc.selection(view.id).primary(), + language_server.offset_encoding(), + ); + + let future = language_server.code_actions(doc.identifier(), range); + let offset_encoding = language_server.offset_encoding(); + + cx.callback( + future, + move |editor, compositor, response: Option| { + let actions = match response { + Some(a) => a, + None => return, + }; + if actions.is_empty() { + editor.set_status("No code actions available"); + return; + } + + let mut picker = ui::Menu::new(actions, move |editor, code_action, event| { + if event != PromptEvent::Validate { + return; + } + + // always present here + let code_action = code_action.unwrap(); + + match code_action { + lsp::CodeActionOrCommand::Command(command) => { + log::debug!("code action command: {:?}", command); + execute_lsp_command(editor, command.clone()); + } + lsp::CodeActionOrCommand::CodeAction(code_action) => { + log::debug!("code action: {:?}", code_action); + if let Some(ref workspace_edit) = code_action.edit { + log::debug!("edit: {:?}", workspace_edit); + apply_workspace_edit(editor, offset_encoding, workspace_edit); + } + + // if code action provides both edit and command first the edit + // should be applied and then the command + if let Some(command) = &code_action.command { + execute_lsp_command(editor, command.clone()); + } + } + } + }); + picker.move_down(); // pre-select the first item + + let popup = Popup::new("code-action", picker).margin(helix_view::graphics::Margin { + vertical: 1, + horizontal: 1, + }); + compositor.replace_or_push("code-action", popup); + }, + ) +} +pub fn execute_lsp_command(editor: &mut Editor, cmd: lsp::Command) { + let doc = doc!(editor); + let language_server = language_server!(editor, doc); + + // the command is executed on the server and communicated back + // to the client asynchronously using workspace edits + let command_future = language_server.command(cmd); + tokio::spawn(async move { + let res = command_future.await; + + if let Err(e) = res { + log::error!("execute LSP command: {}", e); + } + }); +} + +pub fn apply_document_resource_op(op: &lsp::ResourceOp) -> std::io::Result<()> { + use lsp::ResourceOp; + use std::fs; + match op { + ResourceOp::Create(op) => { + let path = op.uri.to_file_path().unwrap(); + let ignore_if_exists = op.options.as_ref().map_or(false, |options| { + !options.overwrite.unwrap_or(false) && options.ignore_if_exists.unwrap_or(false) + }); + if ignore_if_exists && path.exists() { + Ok(()) + } else { + // Create directory if it does not exist + if let Some(dir) = path.parent() { + if !dir.is_dir() { + fs::create_dir_all(&dir)?; + } + } + + fs::write(&path, []) + } + } + ResourceOp::Delete(op) => { + let path = op.uri.to_file_path().unwrap(); + if path.is_dir() { + let recursive = op + .options + .as_ref() + .and_then(|options| options.recursive) + .unwrap_or(false); + + if recursive { + fs::remove_dir_all(&path) + } else { + fs::remove_dir(&path) + } + } else if path.is_file() { + fs::remove_file(&path) + } else { + Ok(()) + } + } + ResourceOp::Rename(op) => { + let from = op.old_uri.to_file_path().unwrap(); + let to = op.new_uri.to_file_path().unwrap(); + let ignore_if_exists = op.options.as_ref().map_or(false, |options| { + !options.overwrite.unwrap_or(false) && options.ignore_if_exists.unwrap_or(false) + }); + if ignore_if_exists && to.exists() { + Ok(()) + } else { + fs::rename(&from, &to) + } + } + } +} + +pub fn apply_workspace_edit( + editor: &mut Editor, + offset_encoding: OffsetEncoding, + workspace_edit: &lsp::WorkspaceEdit, +) { + let mut apply_edits = |uri: &helix_lsp::Url, text_edits: Vec| { + let path = uri + .to_file_path() + .expect("unable to convert URI to filepath"); + + let current_view_id = view!(editor).id; + let doc_id = editor.open(path, Action::Load).unwrap(); + let doc = editor + .document_mut(doc_id) + .expect("Document for document_changes not found"); + + // Need to determine a view for apply/append_changes_to_history + let selections = doc.selections(); + let view_id = if selections.contains_key(¤t_view_id) { + // use current if possible + current_view_id + } else { + // Hack: we take the first available view_id + selections + .keys() + .next() + .copied() + .expect("No view_id available") + }; + + let transaction = helix_lsp::util::generate_transaction_from_edits( + doc.text(), + text_edits, + offset_encoding, + ); + doc.apply(&transaction, view_id); + doc.append_changes_to_history(view_id); + }; + + if let Some(ref changes) = workspace_edit.changes { + log::debug!("workspace changes: {:?}", changes); + for (uri, text_edits) in changes { + let text_edits = text_edits.to_vec(); + apply_edits(uri, text_edits); + } + return; + // Not sure if it works properly, it'll be safer to just panic here to avoid breaking some parts of code on which code actions will be used + // TODO: find some example that uses workspace changes, and test it + // for (url, edits) in changes.iter() { + // let file_path = url.origin().ascii_serialization(); + // let file_path = std::path::PathBuf::from(file_path); + // let file = std::fs::File::open(file_path).unwrap(); + // let mut text = Rope::from_reader(file).unwrap(); + // let transaction = edits_to_changes(&text, edits); + // transaction.apply(&mut text); + // } + } + + if let Some(ref document_changes) = workspace_edit.document_changes { + match document_changes { + lsp::DocumentChanges::Edits(document_edits) => { + for document_edit in document_edits { + let edits = document_edit + .edits + .iter() + .map(|edit| match edit { + lsp::OneOf::Left(text_edit) => text_edit, + lsp::OneOf::Right(annotated_text_edit) => { + &annotated_text_edit.text_edit + } + }) + .cloned() + .collect(); + apply_edits(&document_edit.text_document.uri, edits); + } + } + lsp::DocumentChanges::Operations(operations) => { + log::debug!("document changes - operations: {:?}", operations); + for operateion in operations { + match operateion { + lsp::DocumentChangeOperation::Op(op) => { + apply_document_resource_op(op).unwrap(); + } + + lsp::DocumentChangeOperation::Edit(document_edit) => { + let edits = document_edit + .edits + .iter() + .map(|edit| match edit { + lsp::OneOf::Left(text_edit) => text_edit, + lsp::OneOf::Right(annotated_text_edit) => { + &annotated_text_edit.text_edit + } + }) + .cloned() + .collect(); + apply_edits(&document_edit.text_document.uri, edits); + } + } + } + } + } + } +} +fn goto_impl( + editor: &mut Editor, + compositor: &mut Compositor, + locations: Vec, + offset_encoding: OffsetEncoding, +) { + push_jump(editor); + + let cwdir = std::env::current_dir().expect("couldn't determine current directory"); + + match locations.as_slice() { + [location] => { + jump_to_location(editor, location, offset_encoding, Action::Replace); + } + [] => { + editor.set_error("No definition found."); + } + _locations => { + let picker = FilePicker::new( + locations, + move |location| { + let file: Cow<'_, str> = (location.uri.scheme() == "file") + .then(|| { + location + .uri + .to_file_path() + .map(|path| { + // strip root prefix + path.strip_prefix(&cwdir) + .map(|path| path.to_path_buf()) + .unwrap_or(path) + }) + .map(|path| Cow::from(path.to_string_lossy().into_owned())) + .ok() + }) + .flatten() + .unwrap_or_else(|| location.uri.as_str().into()); + let line = location.range.start.line; + format!("{}:{}", file, line).into() + }, + move |cx, location, action| { + jump_to_location(cx.editor, location, offset_encoding, action) + }, + move |_editor, location| Some(location_to_file_location(location)), + ); + compositor.push(Box::new(overlayed(picker))); + } + } +} + +fn to_locations(definitions: Option) -> Vec { + match definitions { + Some(lsp::GotoDefinitionResponse::Scalar(location)) => vec![location], + Some(lsp::GotoDefinitionResponse::Array(locations)) => locations, + Some(lsp::GotoDefinitionResponse::Link(locations)) => locations + .into_iter() + .map(|location_link| lsp::Location { + uri: location_link.target_uri, + range: location_link.target_range, + }) + .collect(), + None => Vec::new(), + } +} + +pub fn goto_definition(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + let language_server = language_server!(cx.editor, doc); + let offset_encoding = language_server.offset_encoding(); + + let pos = doc.position(view.id, offset_encoding); + + let future = language_server.goto_definition(doc.identifier(), pos, None); + + cx.callback( + future, + move |editor, compositor, response: Option| { + let items = to_locations(response); + goto_impl(editor, compositor, items, offset_encoding); + }, + ); +} + +pub fn goto_type_definition(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + let language_server = language_server!(cx.editor, doc); + let offset_encoding = language_server.offset_encoding(); + + let pos = doc.position(view.id, offset_encoding); + + let future = language_server.goto_type_definition(doc.identifier(), pos, None); + + cx.callback( + future, + move |editor, compositor, response: Option| { + let items = to_locations(response); + goto_impl(editor, compositor, items, offset_encoding); + }, + ); +} + +pub fn goto_implementation(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + let language_server = language_server!(cx.editor, doc); + let offset_encoding = language_server.offset_encoding(); + + let pos = doc.position(view.id, offset_encoding); + + let future = language_server.goto_implementation(doc.identifier(), pos, None); + + cx.callback( + future, + move |editor, compositor, response: Option| { + let items = to_locations(response); + goto_impl(editor, compositor, items, offset_encoding); + }, + ); +} + +pub fn goto_reference(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + let language_server = language_server!(cx.editor, doc); + let offset_encoding = language_server.offset_encoding(); + + let pos = doc.position(view.id, offset_encoding); + + let future = language_server.goto_reference(doc.identifier(), pos, None); + + cx.callback( + future, + move |editor, compositor, response: Option>| { + let items = response.unwrap_or_default(); + goto_impl(editor, compositor, items, offset_encoding); + }, + ); +} + +pub fn signature_help(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + let language_server = language_server!(cx.editor, doc); + let offset_encoding = language_server.offset_encoding(); + + let pos = doc.position(view.id, offset_encoding); + + let future = language_server.text_document_signature_help(doc.identifier(), pos, None); + + cx.callback( + future, + move |_editor, _compositor, response: Option| { + if let Some(signature_help) = response { + log::info!("{:?}", signature_help); + // signatures + // active_signature + // active_parameter + // render as: + + // signature + // ---------- + // doc + + // with active param highlighted + } + }, + ); +} +pub fn hover(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + let language_server = language_server!(cx.editor, doc); + let offset_encoding = language_server.offset_encoding(); + + // TODO: factor out a doc.position_identifier() that returns lsp::TextDocumentPositionIdentifier + + let pos = doc.position(view.id, offset_encoding); + + let future = language_server.text_document_hover(doc.identifier(), pos, None); + + cx.callback( + future, + move |editor, compositor, response: Option| { + if let Some(hover) = response { + // hover.contents / .range <- used for visualizing + + fn marked_string_to_markdown(contents: lsp::MarkedString) -> String { + match contents { + lsp::MarkedString::String(contents) => contents, + lsp::MarkedString::LanguageString(string) => { + if string.language == "markdown" { + string.value + } else { + format!("```{}\n{}\n```", string.language, string.value) + } + } + } + } + + let contents = match hover.contents { + lsp::HoverContents::Scalar(contents) => marked_string_to_markdown(contents), + lsp::HoverContents::Array(contents) => contents + .into_iter() + .map(marked_string_to_markdown) + .collect::>() + .join("\n\n"), + lsp::HoverContents::Markup(contents) => contents.value, + }; + + // skip if contents empty + + let contents = ui::Markdown::new(contents, editor.syn_loader.clone()); + let popup = Popup::new("hover", contents).auto_close(true); + compositor.replace_or_push("hover", popup); + } + }, + ); +} +pub fn rename_symbol(cx: &mut Context) { + ui::prompt( + cx, + "rename-to:".into(), + None, + ui::completers::none, + move |cx: &mut compositor::Context, input: &str, event: PromptEvent| { + if event != PromptEvent::Validate { + return; + } + + let (view, doc) = current!(cx.editor); + let language_server = language_server!(cx.editor, doc); + let offset_encoding = language_server.offset_encoding(); + + let pos = doc.position(view.id, offset_encoding); + + let task = language_server.rename_symbol(doc.identifier(), pos, input.to_string()); + let edits = block_on(task).unwrap_or_default(); + apply_workspace_edit(cx.editor, offset_encoding, &edits); + }, + ); +} diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs new file mode 100644 index 000000000..21365e0a8 --- /dev/null +++ b/helix-term/src/commands/typed.rs @@ -0,0 +1,1686 @@ +use super::*; + +use helix_view::editor::{Action, ConfigEvent}; +use ui::completers::{self, Completer}; + +#[derive(Clone)] +pub struct TypableCommand { + pub name: &'static str, + pub aliases: &'static [&'static str], + pub doc: &'static str, + // params, flags, helper, completer + pub fun: fn(&mut compositor::Context, &[Cow], PromptEvent) -> anyhow::Result<()>, + pub completer: Option, +} + +fn quit( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + // last view and we have unsaved changes + if cx.editor.tree.views().count() == 1 { + buffers_remaining_impl(cx.editor)? + } + + cx.editor.close(view!(cx.editor).id); + + Ok(()) +} + +fn force_quit( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + cx.editor.close(view!(cx.editor).id); + + Ok(()) +} + +fn open( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + ensure!(!args.is_empty(), "wrong argument count"); + for arg in args { + let (path, pos) = args::parse_file(arg); + let _ = cx.editor.open(path, Action::Replace)?; + let (view, doc) = current!(cx.editor); + let pos = Selection::point(pos_at_coords(doc.text().slice(..), pos, true)); + doc.set_selection(view.id, pos); + // does not affect opening a buffer without pos + align_view(doc, view, Align::Center); + } + Ok(()) +} + +fn buffer_close_by_ids_impl( + editor: &mut Editor, + doc_ids: &[DocumentId], + force: bool, +) -> anyhow::Result<()> { + for &doc_id in doc_ids { + editor.close_document(doc_id, force)?; + } + + Ok(()) +} + +fn buffer_gather_paths_impl(editor: &mut Editor, args: &[Cow]) -> Vec { + // No arguments implies current document + if args.is_empty() { + let doc_id = view!(editor).doc; + return vec![doc_id]; + } + + let mut nonexistent_buffers = vec![]; + let mut document_ids = vec![]; + for arg in args { + let doc_id = editor.documents().find_map(|doc| { + let arg_path = Some(Path::new(arg.as_ref())); + if doc.path().map(|p| p.as_path()) == arg_path + || doc.relative_path().as_deref() == arg_path + { + Some(doc.id()) + } else { + None + } + }); + + match doc_id { + Some(doc_id) => document_ids.push(doc_id), + None => nonexistent_buffers.push(format!("'{}'", arg)), + } + } + + if !nonexistent_buffers.is_empty() { + editor.set_error(format!( + "cannot close non-existent buffers: {}", + nonexistent_buffers.join(", ") + )); + } + + document_ids +} + +fn buffer_close( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let document_ids = buffer_gather_paths_impl(cx.editor, args); + buffer_close_by_ids_impl(cx.editor, &document_ids, false) +} + +fn force_buffer_close( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let document_ids = buffer_gather_paths_impl(cx.editor, args); + buffer_close_by_ids_impl(cx.editor, &document_ids, true) +} + +fn buffer_gather_others_impl(editor: &mut Editor) -> Vec { + let current_document = &doc!(editor).id(); + editor + .documents() + .map(|doc| doc.id()) + .filter(|doc_id| doc_id != current_document) + .collect() +} + +fn buffer_close_others( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let document_ids = buffer_gather_others_impl(cx.editor); + buffer_close_by_ids_impl(cx.editor, &document_ids, false) +} + +fn force_buffer_close_others( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let document_ids = buffer_gather_others_impl(cx.editor); + buffer_close_by_ids_impl(cx.editor, &document_ids, true) +} + +fn buffer_gather_all_impl(editor: &mut Editor) -> Vec { + editor.documents().map(|doc| doc.id()).collect() +} + +fn buffer_close_all( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let document_ids = buffer_gather_all_impl(cx.editor); + buffer_close_by_ids_impl(cx.editor, &document_ids, false) +} + +fn force_buffer_close_all( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let document_ids = buffer_gather_all_impl(cx.editor); + buffer_close_by_ids_impl(cx.editor, &document_ids, true) +} + +fn write_impl(cx: &mut compositor::Context, path: Option<&Cow>) -> anyhow::Result<()> { + let jobs = &mut cx.jobs; + let doc = doc_mut!(cx.editor); + + if let Some(ref path) = path { + doc.set_path(Some(path.as_ref().as_ref())) + .context("invalid filepath")?; + } + if doc.path().is_none() { + bail!("cannot write a buffer without a filename"); + } + let fmt = doc.auto_format().map(|fmt| { + let shared = fmt.shared(); + let callback = make_format_callback( + doc.id(), + doc.version(), + Modified::SetUnmodified, + shared.clone(), + ); + jobs.callback(callback); + shared + }); + let future = doc.format_and_save(fmt); + cx.jobs.add(Job::new(future).wait_before_exiting()); + + if path.is_some() { + let id = doc.id(); + let _ = cx.editor.refresh_language_server(id); + } + Ok(()) +} + +fn write( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + write_impl(cx, args.first()) +} + +fn new_file( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + cx.editor.new_file(Action::Replace); + + Ok(()) +} + +fn format( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let doc = doc!(cx.editor); + if let Some(format) = doc.format() { + let callback = + make_format_callback(doc.id(), doc.version(), Modified::LeaveModified, format); + cx.jobs.callback(callback); + } + + Ok(()) +} +fn set_indent_style( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + use IndentStyle::*; + + // If no argument, report current indent style. + if args.is_empty() { + let style = doc!(cx.editor).indent_style; + cx.editor.set_status(match style { + Tabs => "tabs".to_owned(), + Spaces(1) => "1 space".to_owned(), + Spaces(n) if (2..=8).contains(&n) => format!("{} spaces", n), + _ => unreachable!(), // Shouldn't happen. + }); + return Ok(()); + } + + // Attempt to parse argument as an indent style. + let style = match args.get(0) { + Some(arg) if "tabs".starts_with(&arg.to_lowercase()) => Some(Tabs), + Some(Cow::Borrowed("0")) => Some(Tabs), + Some(arg) => arg + .parse::() + .ok() + .filter(|n| (1..=8).contains(n)) + .map(Spaces), + _ => None, + }; + + let style = style.context("invalid indent style")?; + let doc = doc_mut!(cx.editor); + doc.indent_style = style; + + Ok(()) +} + +/// Sets or reports the current document's line ending setting. +fn set_line_ending( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + use LineEnding::*; + + // If no argument, report current line ending setting. + if args.is_empty() { + let line_ending = doc!(cx.editor).line_ending; + cx.editor.set_status(match line_ending { + Crlf => "crlf", + LF => "line feed", + #[cfg(feature = "unicode-lines")] + FF => "form feed", + #[cfg(feature = "unicode-lines")] + CR => "carriage return", + #[cfg(feature = "unicode-lines")] + Nel => "next line", + + // These should never be a document's default line ending. + #[cfg(feature = "unicode-lines")] + VT | LS | PS => "error", + }); + + return Ok(()); + } + + let arg = args + .get(0) + .context("argument missing")? + .to_ascii_lowercase(); + + // Attempt to parse argument as a line ending. + let line_ending = match arg { + // We check for CR first because it shares a common prefix with CRLF. + #[cfg(feature = "unicode-lines")] + arg if arg.starts_with("cr") => CR, + arg if arg.starts_with("crlf") => Crlf, + arg if arg.starts_with("lf") => LF, + #[cfg(feature = "unicode-lines")] + arg if arg.starts_with("ff") => FF, + #[cfg(feature = "unicode-lines")] + arg if arg.starts_with("nel") => Nel, + _ => bail!("invalid line ending"), + }; + + doc_mut!(cx.editor).line_ending = line_ending; + Ok(()) +} + +fn earlier( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let uk = args.join(" ").parse::().map_err(|s| anyhow!(s))?; + + let (view, doc) = current!(cx.editor); + let success = doc.earlier(view.id, uk); + if !success { + cx.editor.set_status("Already at oldest change"); + } + + Ok(()) +} + +fn later( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let uk = args.join(" ").parse::().map_err(|s| anyhow!(s))?; + let (view, doc) = current!(cx.editor); + let success = doc.later(view.id, uk); + if !success { + cx.editor.set_status("Already at newest change"); + } + + Ok(()) +} + +fn write_quit( + cx: &mut compositor::Context, + args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { + write_impl(cx, args.first())?; + quit(cx, &[], event) +} + +fn force_write_quit( + cx: &mut compositor::Context, + args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { + write_impl(cx, args.first())?; + force_quit(cx, &[], event) +} + +/// Results an error if there are modified buffers remaining and sets editor error, +/// otherwise returns `Ok(())` +pub(super) fn buffers_remaining_impl(editor: &mut Editor) -> anyhow::Result<()> { + let modified: Vec<_> = editor + .documents() + .filter(|doc| doc.is_modified()) + .map(|doc| { + doc.relative_path() + .map(|path| path.to_string_lossy().to_string()) + .unwrap_or_else(|| SCRATCH_BUFFER_NAME.into()) + }) + .collect(); + if !modified.is_empty() { + bail!( + "{} unsaved buffer(s) remaining: {:?}", + modified.len(), + modified + ); + } + Ok(()) +} + +fn write_all_impl( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, + quit: bool, + force: bool, +) -> anyhow::Result<()> { + let mut errors = String::new(); + let jobs = &mut cx.jobs; + // save all documents + for doc in &mut cx.editor.documents.values_mut() { + if doc.path().is_none() { + errors.push_str("cannot write a buffer without a filename\n"); + continue; + } + + if !doc.is_modified() { + continue; + } + + let fmt = doc.auto_format().map(|fmt| { + let shared = fmt.shared(); + let callback = make_format_callback( + doc.id(), + doc.version(), + Modified::SetUnmodified, + shared.clone(), + ); + jobs.callback(callback); + shared + }); + let future = doc.format_and_save(fmt); + jobs.add(Job::new(future).wait_before_exiting()); + } + + if quit { + if !force { + buffers_remaining_impl(cx.editor)?; + } + + // close all views + let views: Vec<_> = cx.editor.tree.views().map(|(view, _)| view.id).collect(); + for view_id in views { + cx.editor.close(view_id); + } + } + + bail!(errors) +} + +fn write_all( + cx: &mut compositor::Context, + args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { + write_all_impl(cx, args, event, false, false) +} + +fn write_all_quit( + cx: &mut compositor::Context, + args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { + write_all_impl(cx, args, event, true, false) +} + +fn force_write_all_quit( + cx: &mut compositor::Context, + args: &[Cow], + event: PromptEvent, +) -> anyhow::Result<()> { + write_all_impl(cx, args, event, true, true) +} + +fn quit_all_impl(editor: &mut Editor, force: bool) -> anyhow::Result<()> { + if !force { + buffers_remaining_impl(editor)?; + } + + // close all views + let views: Vec<_> = editor.tree.views().map(|(view, _)| view.id).collect(); + for view_id in views { + editor.close(view_id); + } + + Ok(()) +} + +fn quit_all( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + quit_all_impl(cx.editor, false) +} + +fn force_quit_all( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + quit_all_impl(cx.editor, true) +} + +fn cquit( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let exit_code = args + .first() + .and_then(|code| code.parse::().ok()) + .unwrap_or(1); + cx.editor.exit_code = exit_code; + + quit_all_impl(cx.editor, false) +} + +fn force_cquit( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let exit_code = args + .first() + .and_then(|code| code.parse::().ok()) + .unwrap_or(1); + cx.editor.exit_code = exit_code; + + quit_all_impl(cx.editor, true) +} + +fn theme( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let theme = args.first().context("Theme not provided")?; + let theme = cx + .editor + .theme_loader + .load(theme) + .with_context(|| format!("Failed setting theme {}", theme))?; + let true_color = cx.editor.config().true_color || crate::true_color(); + if !(true_color || theme.is_16_color()) { + bail!("Unsupported theme: theme requires true color support"); + } + cx.editor.set_theme(theme); + Ok(()) +} + +fn yank_main_selection_to_clipboard( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + yank_main_selection_to_clipboard_impl(cx.editor, ClipboardType::Clipboard) +} + +fn yank_joined_to_clipboard( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let doc = doc!(cx.editor); + let default_sep = Cow::Borrowed(doc.line_ending.as_str()); + let separator = args.first().unwrap_or(&default_sep); + yank_joined_to_clipboard_impl(cx.editor, separator, ClipboardType::Clipboard) +} + +fn yank_main_selection_to_primary_clipboard( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + yank_main_selection_to_clipboard_impl(cx.editor, ClipboardType::Selection) +} + +fn yank_joined_to_primary_clipboard( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let doc = doc!(cx.editor); + let default_sep = Cow::Borrowed(doc.line_ending.as_str()); + let separator = args.first().unwrap_or(&default_sep); + yank_joined_to_clipboard_impl(cx.editor, separator, ClipboardType::Selection) +} + +fn paste_clipboard_after( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + paste_clipboard_impl(cx.editor, Paste::After, ClipboardType::Clipboard, 1) +} + +fn paste_clipboard_before( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + paste_clipboard_impl(cx.editor, Paste::After, ClipboardType::Clipboard, 1) +} + +fn paste_primary_clipboard_after( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + paste_clipboard_impl(cx.editor, Paste::After, ClipboardType::Selection, 1) +} + +fn paste_primary_clipboard_before( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + paste_clipboard_impl(cx.editor, Paste::After, ClipboardType::Selection, 1) +} + +fn replace_selections_with_clipboard_impl( + cx: &mut compositor::Context, + clipboard_type: ClipboardType, +) -> anyhow::Result<()> { + let (view, doc) = current!(cx.editor); + + match cx.editor.clipboard_provider.get_contents(clipboard_type) { + Ok(contents) => { + let selection = doc.selection(view.id); + let transaction = Transaction::change_by_selection(doc.text(), selection, |range| { + (range.from(), range.to(), Some(contents.as_str().into())) + }); + + doc.apply(&transaction, view.id); + doc.append_changes_to_history(view.id); + Ok(()) + } + Err(e) => Err(e.context("Couldn't get system clipboard contents")), + } +} + +fn replace_selections_with_clipboard( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + replace_selections_with_clipboard_impl(cx, ClipboardType::Clipboard) +} + +fn replace_selections_with_primary_clipboard( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + replace_selections_with_clipboard_impl(cx, ClipboardType::Selection) +} + +fn show_clipboard_provider( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + cx.editor + .set_status(cx.editor.clipboard_provider.name().to_string()); + Ok(()) +} + +fn change_current_directory( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let dir = helix_core::path::expand_tilde( + args.first() + .context("target directory not provided")? + .as_ref() + .as_ref(), + ); + + if let Err(e) = std::env::set_current_dir(dir) { + bail!("Couldn't change the current working directory: {}", e); + } + + let cwd = std::env::current_dir().context("Couldn't get the new working directory")?; + cx.editor.set_status(format!( + "Current working directory is now {}", + cwd.display() + )); + Ok(()) +} + +fn show_current_directory( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let cwd = std::env::current_dir().context("Couldn't get the new working directory")?; + cx.editor + .set_status(format!("Current working directory is {}", cwd.display())); + Ok(()) +} + +/// Sets the [`Document`]'s encoding.. +fn set_encoding( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let doc = doc_mut!(cx.editor); + if let Some(label) = args.first() { + doc.set_encoding(label) + } else { + let encoding = doc.encoding().name().to_owned(); + cx.editor.set_status(encoding); + Ok(()) + } +} + +/// Reload the [`Document`] from its source file. +fn reload( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let (view, doc) = current!(cx.editor); + doc.reload(view.id) +} + +fn tree_sitter_scopes( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + + let pos = doc.selection(view.id).primary().cursor(text); + let scopes = indent::get_scopes(doc.syntax(), text, pos); + cx.editor.set_status(format!("scopes: {:?}", &scopes)); + Ok(()) +} + +fn vsplit( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let id = view!(cx.editor).doc; + + if args.is_empty() { + cx.editor.switch(id, Action::VerticalSplit); + } else { + for arg in args { + cx.editor + .open(PathBuf::from(arg.as_ref()), Action::VerticalSplit)?; + } + } + + Ok(()) +} + +fn hsplit( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let id = view!(cx.editor).doc; + + if args.is_empty() { + cx.editor.switch(id, Action::HorizontalSplit); + } else { + for arg in args { + cx.editor + .open(PathBuf::from(arg.as_ref()), Action::HorizontalSplit)?; + } + } + + Ok(()) +} + +fn vsplit_new( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + cx.editor.new_file(Action::VerticalSplit); + + Ok(()) +} + +fn hsplit_new( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + cx.editor.new_file(Action::HorizontalSplit); + + Ok(()) +} + +fn debug_eval( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + if let Some(debugger) = cx.editor.debugger.as_mut() { + let (frame, thread_id) = match (debugger.active_frame, debugger.thread_id) { + (Some(frame), Some(thread_id)) => (frame, thread_id), + _ => { + bail!("Cannot find current stack frame to access variables") + } + }; + + // TODO: support no frame_id + + let frame_id = debugger.stack_frames[&thread_id][frame].id; + let response = helix_lsp::block_on(debugger.eval(args.join(" "), Some(frame_id)))?; + cx.editor.set_status(response.result); + } + Ok(()) +} + +fn debug_start( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let mut args = args.to_owned(); + let name = match args.len() { + 0 => None, + _ => Some(args.remove(0)), + }; + dap_start_impl(cx, name.as_deref(), None, Some(args)) +} + +fn debug_remote( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let mut args = args.to_owned(); + let address = match args.len() { + 0 => None, + _ => Some(args.remove(0).parse()?), + }; + let name = match args.len() { + 0 => None, + _ => Some(args.remove(0)), + }; + dap_start_impl(cx, name.as_deref(), address, Some(args)) +} + +fn tutor( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let path = helix_loader::runtime_dir().join("tutor.txt"); + cx.editor.open(path, Action::Replace)?; + // Unset path to prevent accidentally saving to the original tutor file. + doc_mut!(cx.editor).set_path(None)?; + Ok(()) +} + +pub(super) fn goto_line_number( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + ensure!(!args.is_empty(), "Line number required"); + + let line = args[0].parse::()?; + + goto_line_impl(cx.editor, NonZeroUsize::new(line)); + + let (view, doc) = current!(cx.editor); + + view.ensure_cursor_in_view(doc, line); + Ok(()) +} + +/// Change config at runtime. Access nested values by dot syntax, for +/// example to disable smart case search, use `:set search.smart-case false`. +fn setting( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + if args.len() != 2 { + anyhow::bail!("Bad arguments. Usage: `:set key field`"); + } + let (key, arg) = (&args[0].to_lowercase(), &args[1]); + + let key_error = || anyhow::anyhow!("Unknown key `{}`", key); + let field_error = |_| anyhow::anyhow!("Could not parse field `{}`", arg); + + let mut config = serde_json::to_value(&cx.editor.config().clone()).unwrap(); + let pointer = format!("/{}", key.replace('.', "/")); + let value = config.pointer_mut(&pointer).ok_or_else(key_error)?; + + *value = if value.is_string() { + // JSON strings require quotes, so we can't .parse() directly + serde_json::Value::String(arg.to_string()) + } else { + arg.parse().map_err(field_error)? + }; + let config = serde_json::from_value(config).map_err(field_error)?; + + cx.editor + .config_events + .0 + .send(ConfigEvent::Update(config))?; + Ok(()) +} + +fn sort( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + sort_impl(cx, args, false) +} + +fn sort_reverse( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + sort_impl(cx, args, true) +} + +fn sort_impl( + cx: &mut compositor::Context, + _args: &[Cow], + reverse: bool, +) -> anyhow::Result<()> { + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + + let selection = doc.selection(view.id); + + let mut fragments: Vec<_> = selection + .fragments(text) + .map(|fragment| Tendril::from(fragment.as_ref())) + .collect(); + + fragments.sort_by(match reverse { + true => |a: &Tendril, b: &Tendril| b.cmp(a), + false => |a: &Tendril, b: &Tendril| a.cmp(b), + }); + + let transaction = Transaction::change( + doc.text(), + selection + .into_iter() + .zip(fragments) + .map(|(s, fragment)| (s.from(), s.to(), Some(fragment))), + ); + + doc.apply(&transaction, view.id); + doc.append_changes_to_history(view.id); + + Ok(()) +} + +fn tree_sitter_subtree( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + let (view, doc) = current!(cx.editor); + + if let Some(syntax) = doc.syntax() { + let primary_selection = doc.selection(view.id).primary(); + let text = doc.text(); + let from = text.char_to_byte(primary_selection.from()); + let to = text.char_to_byte(primary_selection.to()); + if let Some(selected_node) = syntax + .tree() + .root_node() + .descendant_for_byte_range(from, to) + { + let contents = format!("```tsq\n{}\n```", selected_node.to_sexp()); + + let callback = async move { + let call: job::Callback = + Box::new(move |editor: &mut Editor, compositor: &mut Compositor| { + let contents = ui::Markdown::new(contents, editor.syn_loader.clone()); + let popup = Popup::new("hover", contents).auto_close(true); + compositor.replace_or_push("hover", popup); + }); + Ok(call) + }; + + cx.jobs.callback(callback); + } + } + + Ok(()) +} + +fn open_config( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + cx.editor + .open(helix_loader::config_file(), Action::Replace)?; + Ok(()) +} + +fn refresh_config( + cx: &mut compositor::Context, + _args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + cx.editor.config_events.0.send(ConfigEvent::Refresh)?; + Ok(()) +} + +fn help( + cx: &mut compositor::Context, + args: &[Cow], + _event: PromptEvent, +) -> anyhow::Result<()> { + if args.is_empty() { + // TODO: Open a list of commands? + todo!() + } + + if args[0] == "topics" { + let dir_path = helix_loader::runtime_dir().join("help/topics"); + let entries: Vec = std::fs::read_dir(dir_path) + .map(|entries| { + entries + .filter_map(|entry| { + let entry = entry.ok()?; + let path = entry.path(); + Some(path) + }) + .collect() + }) + .unwrap_or_default(); + + let callback = Box::pin(async move { + let call: job::Callback = + Box::new(move |_editor: &mut Editor, compositor: &mut Compositor| { + let picker = FilePicker::new( + entries, + |path| { + path.file_stem() + .and_then(|s| s.to_str()) + .map(From::from) + .unwrap_or_default() + }, + |cx, path, _action| { + if let Err(e) = cx + .editor + .open(path.clone(), Action::HorizontalSplit) + .and_then(|id| { + cx.editor + .document_mut(id) + .unwrap() + .set_path(None) + .map_err(Into::into) + }) + { + cx.editor.set_error(e.to_string()); + } + }, + |_editor, path| Some((path.clone(), None)), + ); + compositor.push(Box::new(picker)); + }); + Ok(call) + }); + cx.jobs.callback(callback); + + return Ok(()); + } + + let args_msg = args.join(" "); + let open_help = + move |help_dir: &str, command: &str, editor: &mut Editor| -> anyhow::Result<()> { + let mut path = helix_loader::runtime_dir(); + path.push("help"); + path.push(help_dir); + path.push(format!("{}.txt", command)); + + ensure!(path.is_file(), "No help available for '{}'", args_msg); + let id = editor.open(path, Action::HorizontalSplit)?; + editor.document_mut(id).unwrap().set_path(None)?; + Ok(()) + }; + + const STATIC_HELP_DIR: &str = "static-commands"; + const TYPABLE_HELP_DIR: &str = "typable-commands"; + + let (help_dir, command): (&str, &str) = { + let arg = &args[0]; + if let Some(command) = arg.strip_prefix(':').and_then(|arg| { + TYPABLE_COMMAND_LIST.iter().find_map(|command| { + (command.name == arg || command.aliases.iter().any(|alias| *alias == arg)) + .then(|| command.name) + }) + }) { + (TYPABLE_HELP_DIR, command) + } else if MappableCommand::STATIC_COMMAND_LIST + .iter() + .any(|command| command.name() == arg) + { + (STATIC_HELP_DIR, arg) + } else { + let arg = arg.to_owned().into_owned(); + let keys = arg + .parse::() + .map(|key| vec![key]) + .or_else(|_| helix_view::input::parse_macro(&arg))?; + let callback = Box::pin(async move { + let call: job::Callback = + Box::new(move |editor: &mut Editor, compositor: &mut Compositor| { + use crate::keymap::KeymapResult; + let editor_view = compositor.find::().unwrap(); + let mode = doc!(editor).mode; + let keymaps = &mut editor_view.keymaps; + let (keys, last_key) = (&keys[..keys.len() - 1], keys.last().unwrap()); + keys.iter().for_each(|key| { + keymaps.get(mode, *key); + }); + let result = keymaps.get(mode, *last_key); + let res: anyhow::Result<(&str, &str)> = match &result { + KeymapResult::Matched(command) => match command { + MappableCommand::Static { name, .. } => Ok((STATIC_HELP_DIR, name)), + MappableCommand::Typable { name, .. } => { + Ok((TYPABLE_HELP_DIR, name)) + } + }, + KeymapResult::NotFound | KeymapResult::Cancelled(_) => { + Err(anyhow!("No command found for '{}'", arg)) + } + _ => todo!(), + }; + if let Err(e) = + res.and_then(|(help_dir, command)| open_help(help_dir, command, editor)) + { + editor.set_error(e.to_string()); + } + }); + Ok(call) + }); + cx.jobs.callback(callback); + return Ok(()); + } + }; + + open_help(help_dir, command, cx.editor) +} + +pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ + TypableCommand { + name: "quit", + aliases: &["q"], + doc: "Close the current view.", + fun: quit, + completer: None, + }, + TypableCommand { + name: "quit!", + aliases: &["q!"], + doc: "Close the current view forcefully (ignoring unsaved changes).", + fun: force_quit, + completer: None, + }, + TypableCommand { + name: "open", + aliases: &["o"], + doc: "Open a file from disk into the current view.", + fun: open, + completer: Some(completers::filename), + }, + TypableCommand { + name: "buffer-close", + aliases: &["bc", "bclose"], + doc: "Close the current buffer.", + fun: buffer_close, + completer: Some(completers::buffer), + }, + TypableCommand { + name: "buffer-close!", + aliases: &["bc!", "bclose!"], + doc: "Close the current buffer forcefully (ignoring unsaved changes).", + fun: force_buffer_close, + completer: Some(completers::buffer), + }, + TypableCommand { + name: "buffer-close-others", + aliases: &["bco", "bcloseother"], + doc: "Close all buffers but the currently focused one.", + fun: buffer_close_others, + completer: None, + }, + TypableCommand { + name: "buffer-close-others!", + aliases: &["bco!", "bcloseother!"], + doc: "Close all buffers but the currently focused one.", + fun: force_buffer_close_others, + completer: None, + }, + TypableCommand { + name: "buffer-close-all", + aliases: &["bca", "bcloseall"], + doc: "Close all buffers, without quiting.", + fun: buffer_close_all, + completer: None, + }, + TypableCommand { + name: "buffer-close-all!", + aliases: &["bca!", "bcloseall!"], + doc: "Close all buffers forcefully (ignoring unsaved changes), without quiting.", + fun: force_buffer_close_all, + completer: None, + }, + TypableCommand { + name: "write", + aliases: &["w"], + doc: "Write changes to disk. Accepts an optional path (:write some/path.txt)", + fun: write, + completer: Some(completers::filename), + }, + TypableCommand { + name: "new", + aliases: &["n"], + doc: "Create a new scratch buffer.", + fun: new_file, + completer: Some(completers::filename), + }, + TypableCommand { + name: "format", + aliases: &["fmt"], + doc: "Format the file using the LSP formatter.", + fun: format, + completer: None, + }, + TypableCommand { + name: "indent-style", + aliases: &[], + doc: "Set the indentation style for editing. ('t' for tabs or 1-8 for number of spaces.)", + fun: set_indent_style, + completer: None, + }, + TypableCommand { + name: "line-ending", + aliases: &[], + doc: "Set the document's default line ending. Options: crlf, lf, cr, ff, nel.", + fun: set_line_ending, + completer: None, + }, + TypableCommand { + name: "earlier", + aliases: &["ear"], + doc: "Jump back to an earlier point in edit history. Accepts a number of steps or a time span.", + fun: earlier, + completer: None, + }, + TypableCommand { + name: "later", + aliases: &["lat"], + doc: "Jump to a later point in edit history. Accepts a number of steps or a time span.", + fun: later, + completer: None, + }, + TypableCommand { + name: "write-quit", + aliases: &["wq", "x"], + doc: "Write changes to disk and close the current view. Accepts an optional path (:wq some/path.txt)", + fun: write_quit, + completer: Some(completers::filename), + }, + TypableCommand { + name: "write-quit!", + aliases: &["wq!", "x!"], + doc: "Write changes to disk and close the current view forcefully. Accepts an optional path (:wq! some/path.txt)", + fun: force_write_quit, + completer: Some(completers::filename), + }, + TypableCommand { + name: "write-all", + aliases: &["wa"], + doc: "Write changes from all views to disk.", + fun: write_all, + completer: None, + }, + TypableCommand { + name: "write-quit-all", + aliases: &["wqa", "xa"], + doc: "Write changes from all views to disk and close all views.", + fun: write_all_quit, + completer: None, + }, + TypableCommand { + name: "write-quit-all!", + aliases: &["wqa!", "xa!"], + doc: "Write changes from all views to disk and close all views forcefully (ignoring unsaved changes).", + fun: force_write_all_quit, + completer: None, + }, + TypableCommand { + name: "quit-all", + aliases: &["qa"], + doc: "Close all views.", + fun: quit_all, + completer: None, + }, + TypableCommand { + name: "quit-all!", + aliases: &["qa!"], + doc: "Close all views forcefully (ignoring unsaved changes).", + fun: force_quit_all, + completer: None, + }, + TypableCommand { + name: "cquit", + aliases: &["cq"], + doc: "Quit with exit code (default 1). Accepts an optional integer exit code (:cq 2).", + fun: cquit, + completer: None, + }, + TypableCommand { + name: "cquit!", + aliases: &["cq!"], + doc: "Quit with exit code (default 1) forcefully (ignoring unsaved changes). Accepts an optional integer exit code (:cq! 2).", + fun: force_cquit, + completer: None, + }, + TypableCommand { + name: "theme", + aliases: &[], + doc: "Change the editor theme.", + fun: theme, + completer: Some(completers::theme), + }, + TypableCommand { + name: "clipboard-yank", + aliases: &[], + doc: "Yank main selection into system clipboard.", + fun: yank_main_selection_to_clipboard, + completer: None, + }, + TypableCommand { + name: "clipboard-yank-join", + aliases: &[], + doc: "Yank joined selections into system clipboard. A separator can be provided as first argument. Default value is newline.", // FIXME: current UI can't display long doc. + fun: yank_joined_to_clipboard, + completer: None, + }, + TypableCommand { + name: "primary-clipboard-yank", + aliases: &[], + doc: "Yank main selection into system primary clipboard.", + fun: yank_main_selection_to_primary_clipboard, + completer: None, + }, + TypableCommand { + name: "primary-clipboard-yank-join", + aliases: &[], + doc: "Yank joined selections into system primary clipboard. A separator can be provided as first argument. Default value is newline.", // FIXME: current UI can't display long doc. + fun: yank_joined_to_primary_clipboard, + completer: None, + }, + TypableCommand { + name: "clipboard-paste-after", + aliases: &[], + doc: "Paste system clipboard after selections.", + fun: paste_clipboard_after, + completer: None, + }, + TypableCommand { + name: "clipboard-paste-before", + aliases: &[], + doc: "Paste system clipboard before selections.", + fun: paste_clipboard_before, + completer: None, + }, + TypableCommand { + name: "clipboard-paste-replace", + aliases: &[], + doc: "Replace selections with content of system clipboard.", + fun: replace_selections_with_clipboard, + completer: None, + }, + TypableCommand { + name: "primary-clipboard-paste-after", + aliases: &[], + doc: "Paste primary clipboard after selections.", + fun: paste_primary_clipboard_after, + completer: None, + }, + TypableCommand { + name: "primary-clipboard-paste-before", + aliases: &[], + doc: "Paste primary clipboard before selections.", + fun: paste_primary_clipboard_before, + completer: None, + }, + TypableCommand { + name: "primary-clipboard-paste-replace", + aliases: &[], + doc: "Replace selections with content of system primary clipboard.", + fun: replace_selections_with_primary_clipboard, + completer: None, + }, + TypableCommand { + name: "show-clipboard-provider", + aliases: &[], + doc: "Show clipboard provider name in status bar.", + fun: show_clipboard_provider, + completer: None, + }, + TypableCommand { + name: "change-current-directory", + aliases: &["cd"], + doc: "Change the current working directory.", + fun: change_current_directory, + completer: Some(completers::directory), + }, + TypableCommand { + name: "show-directory", + aliases: &["pwd"], + doc: "Show the current working directory.", + fun: show_current_directory, + completer: None, + }, + TypableCommand { + name: "encoding", + aliases: &[], + doc: "Set encoding based on `https://encoding.spec.whatwg.org`", + fun: set_encoding, + completer: None, + }, + TypableCommand { + name: "reload", + aliases: &[], + doc: "Discard changes and reload from the source file.", + fun: reload, + completer: None, + }, + TypableCommand { + name: "tree-sitter-scopes", + aliases: &[], + doc: "Display tree sitter scopes, primarily for theming and development.", + fun: tree_sitter_scopes, + completer: None, + }, + TypableCommand { + name: "debug-start", + aliases: &["dbg"], + doc: "Start a debug session from a given template with given parameters.", + fun: debug_start, + completer: None, + }, + TypableCommand { + name: "debug-remote", + aliases: &["dbg-tcp"], + doc: "Connect to a debug adapter by TCP address and start a debugging session from a given template with given parameters.", + fun: debug_remote, + completer: None, + }, + TypableCommand { + name: "debug-eval", + aliases: &[], + doc: "Evaluate expression in current debug context.", + fun: debug_eval, + completer: None, + }, + TypableCommand { + name: "vsplit", + aliases: &["vs"], + doc: "Open the file in a vertical split.", + fun: vsplit, + completer: Some(completers::filename), + }, + TypableCommand { + name: "vsplit-new", + aliases: &["vnew"], + doc: "Open a scratch buffer in a vertical split.", + fun: vsplit_new, + completer: None, + }, + TypableCommand { + name: "hsplit", + aliases: &["hs", "sp"], + doc: "Open the file in a horizontal split.", + fun: hsplit, + completer: Some(completers::filename), + }, + TypableCommand { + name: "hsplit-new", + aliases: &["hnew"], + doc: "Open a scratch buffer in a horizontal split.", + fun: hsplit_new, + completer: None, + }, + TypableCommand { + name: "tutor", + aliases: &[], + doc: "Open the tutorial.", + fun: tutor, + completer: None, + }, + TypableCommand { + name: "goto", + aliases: &["g"], + doc: "Go to line number.", + fun: goto_line_number, + completer: None, + }, + TypableCommand { + name: "set-option", + aliases: &["set"], + doc: "Set a config option at runtime", + fun: setting, + completer: Some(completers::setting), + }, + TypableCommand { + name: "sort", + aliases: &[], + doc: "Sort ranges in selection.", + fun: sort, + completer: None, + }, + TypableCommand { + name: "rsort", + aliases: &[], + doc: "Sort ranges in selection in reverse order.", + fun: sort_reverse, + completer: None, + }, + TypableCommand { + name: "tree-sitter-subtree", + aliases: &["ts-subtree"], + doc: "Display tree sitter subtree under cursor, primarily for debugging queries.", + fun: tree_sitter_subtree, + completer: None, + }, + TypableCommand { + name: "config-reload", + aliases: &[], + doc: "Refreshes helix's config.", + fun: refresh_config, + completer: None, + }, + TypableCommand { + name: "config-open", + aliases: &[], + doc: "Open the helix config.toml file.", + fun: open_config, + completer: None, + }, + TypableCommand { + name: "help", + aliases: &["h"], + doc: "Open documentation for a command or keybind.", + fun: help, + completer: Some(completers::help), + }, + ]; + +pub static TYPABLE_COMMAND_MAP: Lazy> = + Lazy::new(|| { + TYPABLE_COMMAND_LIST + .iter() + .flat_map(|cmd| { + std::iter::once((cmd.name, cmd)) + .chain(cmd.aliases.iter().map(move |&alias| (alias, cmd))) + }) + .collect() + }); + +pub fn command_mode(cx: &mut Context) { + let mut prompt = Prompt::new( + ":".into(), + Some(':'), + |editor: &Editor, input: &str| { + static FUZZY_MATCHER: Lazy = + Lazy::new(fuzzy_matcher::skim::SkimMatcherV2::default); + + // we use .this over split_whitespace() because we care about empty segments + let parts = input.split(' ').collect::>(); + + // simple heuristic: if there's no just one part, complete command name. + // if there's a space, per command completion kicks in. + if parts.len() <= 1 { + let mut matches: Vec<_> = typed::TYPABLE_COMMAND_LIST + .iter() + .filter_map(|command| { + FUZZY_MATCHER + .fuzzy_match(command.name, input) + .map(|score| (command.name, score)) + }) + .collect(); + + matches.sort_unstable_by_key(|(_file, score)| std::cmp::Reverse(*score)); + matches + .into_iter() + .map(|(name, _)| (0.., name.into())) + .collect() + } else { + let part = parts.last().unwrap(); + + if let Some(typed::TypableCommand { + completer: Some(completer), + .. + }) = typed::TYPABLE_COMMAND_MAP.get(parts[0]) + { + completer(editor, part) + .into_iter() + .map(|(range, file)| { + // offset ranges to input + let offset = input.len() - part.len(); + let range = (range.start + offset)..; + (range, file) + }) + .collect() + } else { + Vec::new() + } + } + }, // completion + move |cx: &mut compositor::Context, input: &str, event: PromptEvent| { + if event != PromptEvent::Validate { + return; + } + + let parts = input.split_whitespace().collect::>(); + if parts.is_empty() { + return; + } + + // If command is numeric, interpret as line number and go there. + if parts.len() == 1 && parts[0].parse::().ok().is_some() { + if let Err(e) = typed::goto_line_number(cx, &[Cow::from(parts[0])], event) { + cx.editor.set_error(format!("{}", e)); + } + return; + } + + // Handle typable commands + if let Some(cmd) = typed::TYPABLE_COMMAND_MAP.get(parts[0]) { + let args = if cfg!(unix) { + shellwords::shellwords(input) + } else { + // Windows doesn't support POSIX, so fallback for now + parts + .into_iter() + .map(|part| part.into()) + .collect::>() + }; + + if let Err(e) = (cmd.fun)(cx, &args[1..], event) { + cx.editor.set_error(format!("{}", e)); + } + } else { + cx.editor + .set_error(format!("no such command: '{}'", parts[0])); + }; + }, + ); + prompt.doc_fn = Box::new(|input: &str| { + let part = input.split(' ').next().unwrap_or_default(); + + if let Some(typed::TypableCommand { doc, aliases, .. }) = + typed::TYPABLE_COMMAND_MAP.get(part) + { + if aliases.is_empty() { + return Some((*doc).into()); + } + return Some(format!("{}\nAliases: {}", doc, aliases.join(", ")).into()); + } + + None + }); + + // Calculate initial completion + prompt.recalculate_completion(cx.editor); + cx.push_layer(Box::new(prompt)); +} diff --git a/helix-term/src/compositor.rs b/helix-term/src/compositor.rs index dd7ebe1d8..e3cec643e 100644 --- a/helix-term/src/compositor.rs +++ b/helix-term/src/compositor.rs @@ -9,17 +9,9 @@ use tui::buffer::Buffer as Surface; pub type Callback = Box; -// --> EventResult should have a callback that takes a context with methods like .popup(), -// .prompt() etc. That way we can abstract it from the renderer. -// Q: How does this interact with popups where we need to be able to specify the rendering of the -// popup? -// A: It could just take a textarea. -// -// If Compositor was specified in the callback that's then problematic because of - // Cursive-inspired pub enum EventResult { - Ignored, + Ignored(Option), Consumed(Option), } @@ -36,7 +28,7 @@ pub struct Context<'a> { pub trait Component: Any + AnyComponent { /// Process input events, return true if handled. fn handle_event(&mut self, _event: Event, _ctx: &mut Context) -> EventResult { - EventResult::Ignored + EventResult::Ignored(None) } // , args: () @@ -128,11 +120,11 @@ impl Compositor { /// Replace a component that has the given `id` with the new layer and if /// no component is found, push the layer normally. - pub fn replace_or_push(&mut self, id: &'static str, layer: Box) { + pub fn replace_or_push(&mut self, id: &'static str, layer: T) { if let Some(component) = self.find_id(id) { *component = layer; } else { - self.push(layer) + self.push(Box::new(layer)) } } @@ -146,19 +138,34 @@ impl Compositor { keys.push(key.into()); } + let mut callbacks = Vec::new(); + let mut consumed = false; + // propagate events through the layers until we either find a layer that consumes it or we // run out of layers (event bubbling) for layer in self.layers.iter_mut().rev() { match layer.handle_event(event, cx) { EventResult::Consumed(Some(callback)) => { - callback(self, cx); - return true; + callbacks.push(callback); + consumed = true; + break; + } + EventResult::Consumed(None) => { + consumed = true; + break; + } + EventResult::Ignored(Some(callback)) => { + callbacks.push(callback); } - EventResult::Consumed(None) => return true, - EventResult::Ignored => false, + EventResult::Ignored(None) => {} }; } - false + + for callback in callbacks { + callback(self, cx) + } + + consumed } pub fn render(&mut self, cx: &mut Context) { @@ -206,10 +213,9 @@ impl Compositor { } pub fn find_id(&mut self, id: &'static str) -> Option<&mut T> { - let type_name = std::any::type_name::(); self.layers .iter_mut() - .find(|component| component.type_name() == type_name && component.id() == Some(id)) + .find(|component| component.id() == Some(id)) .and_then(|component| component.as_any_mut().downcast_mut()) } } diff --git a/helix-term/src/config.rs b/helix-term/src/config.rs index 6b8bbc1b8..4407a882f 100644 --- a/helix-term/src/config.rs +++ b/helix-term/src/config.rs @@ -1,23 +1,60 @@ +use crate::keymap::{default::default, merge_keys, Keymap}; +use helix_view::document::Mode; use serde::Deserialize; +use std::collections::HashMap; +use std::fmt::Display; +use std::io::Error as IOError; +use std::path::PathBuf; +use toml::de::Error as TomlError; -use crate::keymap::Keymaps; - -#[derive(Debug, Default, Clone, PartialEq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Deserialize)] #[serde(deny_unknown_fields)] pub struct Config { pub theme: Option, - #[serde(default)] - pub lsp: LspConfig, - #[serde(default)] - pub keys: Keymaps, + #[serde(default = "default")] + pub keys: HashMap, #[serde(default)] pub editor: helix_view::editor::Config, } -#[derive(Debug, Default, Clone, PartialEq, Deserialize)] -#[serde(rename_all = "kebab-case", deny_unknown_fields)] -pub struct LspConfig { - pub display_messages: bool, +impl Default for Config { + fn default() -> Config { + Config { + theme: None, + keys: default(), + editor: helix_view::editor::Config::default(), + } + } +} + +#[derive(Debug)] +pub enum ConfigLoadError { + BadConfig(TomlError), + Error(IOError), +} + +impl Display for ConfigLoadError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ConfigLoadError::BadConfig(err) => err.fmt(f), + ConfigLoadError::Error(err) => err.fmt(f), + } + } +} + +impl Config { + pub fn load(config_path: PathBuf) -> Result { + match std::fs::read_to_string(config_path) { + Ok(config) => toml::from_str(&config) + .map(merge_keys) + .map_err(ConfigLoadError::BadConfig), + Err(err) => Err(ConfigLoadError::Error(err)), + } + } + + pub fn load_default() -> Result { + Config::load(helix_loader::config_file()) + } } #[cfg(test)] @@ -43,7 +80,7 @@ mod tests { assert_eq!( toml::from_str::(sample_keymaps).unwrap(), Config { - keys: Keymaps(hashmap! { + keys: hashmap! { Mode::Insert => Keymap::new(keymap!({ "Insert mode" "y" => move_line_down, "S-C-a" => delete_selection, @@ -51,9 +88,20 @@ mod tests { Mode::Normal => Keymap::new(keymap!({ "Normal mode" "A-F12" => move_next_word_end, })), - }), + }, ..Default::default() } ); } + + #[test] + fn keys_resolve_to_correct_defaults() { + // From serde default + let default_keys = toml::from_str::("").unwrap().keys; + assert_eq!(default_keys, default()); + + // From the Default trait + let default_keys = Config::default().keys; + assert_eq!(default_keys, default()); + } } diff --git a/helix-term/src/health.rs b/helix-term/src/health.rs new file mode 100644 index 000000000..f73139fc3 --- /dev/null +++ b/helix-term/src/health.rs @@ -0,0 +1,285 @@ +use crossterm::style::{Color, Print, Stylize}; +use helix_core::config::{default_syntax_loader, user_syntax_loader}; +use helix_loader::grammar::load_runtime_file; +use std::io::Write; + +#[derive(Copy, Clone)] +pub enum TsFeature { + Highlight, + TextObject, + AutoIndent, +} + +impl TsFeature { + pub fn all() -> &'static [Self] { + &[Self::Highlight, Self::TextObject, Self::AutoIndent] + } + + pub fn runtime_filename(&self) -> &'static str { + match *self { + Self::Highlight => "highlights.scm", + Self::TextObject => "textobjects.scm", + Self::AutoIndent => "indents.scm", + } + } + + pub fn long_title(&self) -> &'static str { + match *self { + Self::Highlight => "Syntax Highlighting", + Self::TextObject => "Treesitter Textobjects", + Self::AutoIndent => "Auto Indent", + } + } + + pub fn short_title(&self) -> &'static str { + match *self { + Self::Highlight => "Highlight", + Self::TextObject => "Textobject", + Self::AutoIndent => "Indent", + } + } +} + +/// Display general diagnostics. +pub fn general() -> std::io::Result<()> { + let stdout = std::io::stdout(); + let mut stdout = stdout.lock(); + + let config_file = helix_loader::config_file(); + let lang_file = helix_loader::lang_config_file(); + let log_file = helix_loader::log_file(); + let rt_dir = helix_loader::runtime_dir(); + + if config_file.exists() { + writeln!(stdout, "Config file: {}", config_file.display())?; + } else { + writeln!(stdout, "Config file: default")?; + } + if lang_file.exists() { + writeln!(stdout, "Language file: {}", lang_file.display())?; + } else { + writeln!(stdout, "Language file: default")?; + } + writeln!(stdout, "Log file: {}", log_file.display())?; + writeln!(stdout, "Runtime directory: {}", rt_dir.display())?; + + if let Ok(path) = std::fs::read_link(&rt_dir) { + let msg = format!("Runtime directory is symlinked to {}", path.display()); + writeln!(stdout, "{}", msg.yellow())?; + } + if !rt_dir.exists() { + writeln!(stdout, "{}", "Runtime directory does not exist.".red())?; + } + if rt_dir.read_dir().ok().map(|it| it.count()) == Some(0) { + writeln!(stdout, "{}", "Runtime directory is empty.".red())?; + } + + Ok(()) +} + +pub fn languages_all() -> std::io::Result<()> { + let stdout = std::io::stdout(); + let mut stdout = stdout.lock(); + + let mut syn_loader_conf = match user_syntax_loader() { + Ok(conf) => conf, + Err(err) => { + let stderr = std::io::stderr(); + let mut stderr = stderr.lock(); + + writeln!( + stderr, + "{}: {}", + "Error parsing user language config".red(), + err + )?; + writeln!(stderr, "{}", "Using default language config".yellow())?; + default_syntax_loader() + } + }; + + let mut headings = vec!["Language", "LSP", "DAP"]; + + for feat in TsFeature::all() { + headings.push(feat.short_title()) + } + + let terminal_cols = crossterm::terminal::size().map(|(c, _)| c).unwrap_or(80); + let column_width = terminal_cols as usize / headings.len(); + + let column = |item: &str, color: Color| { + let data = format!( + "{:width$}", + item.get(..column_width - 2) + .map(|s| format!("{}…", s)) + .unwrap_or_else(|| item.to_string()), + width = column_width, + ) + .stylize() + .with(color); + + // We can't directly use println!() because of + // https://github.com/crossterm-rs/crossterm/issues/589 + let _ = crossterm::execute!(std::io::stdout(), Print(data)); + }; + + for heading in headings { + column(heading, Color::White); + } + writeln!(stdout)?; + + syn_loader_conf + .language + .sort_unstable_by_key(|l| l.language_id.clone()); + + let check_binary = |cmd: Option| match cmd { + Some(cmd) => match which::which(&cmd) { + Ok(_) => column(&cmd, Color::Green), + Err(_) => column(&cmd, Color::Red), + }, + None => column("None", Color::Yellow), + }; + + for lang in &syn_loader_conf.language { + column(&lang.language_id, Color::Reset); + + let lsp = lang + .language_server + .as_ref() + .map(|lsp| lsp.command.to_string()); + check_binary(lsp); + + let dap = lang.debugger.as_ref().map(|dap| dap.command.to_string()); + check_binary(dap); + + for ts_feat in TsFeature::all() { + match load_runtime_file(&lang.language_id, ts_feat.runtime_filename()).is_ok() { + true => column("Found", Color::Green), + false => column("Not Found", Color::Red), + } + } + + writeln!(stdout)?; + } + + Ok(()) +} + +/// Display diagnostics pertaining to a particular language (LSP, +/// highlight queries, etc). +pub fn language(lang_str: String) -> std::io::Result<()> { + let stdout = std::io::stdout(); + let mut stdout = stdout.lock(); + + let syn_loader_conf = match user_syntax_loader() { + Ok(conf) => conf, + Err(err) => { + let stderr = std::io::stderr(); + let mut stderr = stderr.lock(); + + writeln!( + stderr, + "{}: {}", + "Error parsing user language config".red(), + err + )?; + writeln!(stderr, "{}", "Using default language config".yellow())?; + default_syntax_loader() + } + }; + + let lang = match syn_loader_conf + .language + .iter() + .find(|l| l.language_id == lang_str) + { + Some(l) => l, + None => { + let msg = format!("Language '{}' not found", lang_str); + writeln!(stdout, "{}", msg.red())?; + let suggestions: Vec<&str> = syn_loader_conf + .language + .iter() + .filter(|l| l.language_id.starts_with(lang_str.chars().next().unwrap())) + .map(|l| l.language_id.as_str()) + .collect(); + if !suggestions.is_empty() { + let suggestions = suggestions.join(", "); + writeln!( + stdout, + "Did you mean one of these: {} ?", + suggestions.yellow() + )?; + } + return Ok(()); + } + }; + + probe_protocol( + "language server", + lang.language_server + .as_ref() + .map(|lsp| lsp.command.to_string()), + )?; + + probe_protocol( + "debug adapter", + lang.debugger.as_ref().map(|dap| dap.command.to_string()), + )?; + + for ts_feat in TsFeature::all() { + probe_treesitter_feature(&lang_str, *ts_feat)? + } + + Ok(()) +} + +/// Display diagnostics about LSP and DAP. +fn probe_protocol(protocol_name: &str, server_cmd: Option) -> std::io::Result<()> { + let stdout = std::io::stdout(); + let mut stdout = stdout.lock(); + + let cmd_name = match server_cmd { + Some(ref cmd) => cmd.as_str().green(), + None => "None".yellow(), + }; + writeln!(stdout, "Configured {}: {}", protocol_name, cmd_name)?; + + if let Some(cmd) = server_cmd { + let path = match which::which(&cmd) { + Ok(path) => path.display().to_string().green(), + Err(_) => "Not found in $PATH".to_string().red(), + }; + writeln!(stdout, "Binary for {}: {}", protocol_name, path)?; + } + + Ok(()) +} + +/// Display diagnostics about a feature that requires tree-sitter +/// query files (highlights, textobjects, etc). +fn probe_treesitter_feature(lang: &str, feature: TsFeature) -> std::io::Result<()> { + let stdout = std::io::stdout(); + let mut stdout = stdout.lock(); + + let found = match load_runtime_file(lang, feature.runtime_filename()).is_ok() { + true => "Found".green(), + false => "Not found".red(), + }; + writeln!(stdout, "{} queries: {}", feature.short_title(), found)?; + + Ok(()) +} + +pub fn print_health(health_arg: Option) -> std::io::Result<()> { + match health_arg.as_deref() { + Some("all") => languages_all()?, + Some(lang) => language(lang.to_string())?, + None => { + general()?; + writeln!(std::io::stdout().lock())?; + languages_all()?; + } + } + Ok(()) +} diff --git a/helix-term/src/keymap.rs b/helix-term/src/keymap.rs index f414f797c..37dbc5de2 100644 --- a/helix-term/src/keymap.rs +++ b/helix-term/src/keymap.rs @@ -1,135 +1,23 @@ +pub mod default; +pub mod macros; + pub use crate::commands::MappableCommand; use crate::config::Config; -use helix_core::hashmap; +use arc_swap::{ + access::{DynAccess, DynGuard}, + ArcSwap, +}; use helix_view::{document::Mode, info::Info, input::KeyEvent}; use serde::Deserialize; use std::{ borrow::Cow, collections::{BTreeSet, HashMap}, ops::{Deref, DerefMut}, + sync::Arc, }; -#[macro_export] -macro_rules! key { - ($key:ident) => { - ::helix_view::input::KeyEvent { - code: ::helix_view::keyboard::KeyCode::$key, - modifiers: ::helix_view::keyboard::KeyModifiers::NONE, - } - }; - ($($ch:tt)*) => { - ::helix_view::input::KeyEvent { - code: ::helix_view::keyboard::KeyCode::Char($($ch)*), - modifiers: ::helix_view::keyboard::KeyModifiers::NONE, - } - }; -} - -#[macro_export] -macro_rules! shift { - ($key:ident) => { - ::helix_view::input::KeyEvent { - code: ::helix_view::keyboard::KeyCode::$key, - modifiers: ::helix_view::keyboard::KeyModifiers::SHIFT, - } - }; - ($($ch:tt)*) => { - ::helix_view::input::KeyEvent { - code: ::helix_view::keyboard::KeyCode::Char($($ch)*), - modifiers: ::helix_view::keyboard::KeyModifiers::SHIFT, - } - }; -} - -#[macro_export] -macro_rules! ctrl { - ($key:ident) => { - ::helix_view::input::KeyEvent { - code: ::helix_view::keyboard::KeyCode::$key, - modifiers: ::helix_view::keyboard::KeyModifiers::CONTROL, - } - }; - ($($ch:tt)*) => { - ::helix_view::input::KeyEvent { - code: ::helix_view::keyboard::KeyCode::Char($($ch)*), - modifiers: ::helix_view::keyboard::KeyModifiers::CONTROL, - } - }; -} - -#[macro_export] -macro_rules! alt { - ($key:ident) => { - ::helix_view::input::KeyEvent { - code: ::helix_view::keyboard::KeyCode::$key, - modifiers: ::helix_view::keyboard::KeyModifiers::ALT, - } - }; - ($($ch:tt)*) => { - ::helix_view::input::KeyEvent { - code: ::helix_view::keyboard::KeyCode::Char($($ch)*), - modifiers: ::helix_view::keyboard::KeyModifiers::ALT, - } - }; -} - -/// Macro for defining the root of a `Keymap` object. Example: -/// -/// ``` -/// # use helix_core::hashmap; -/// # use helix_term::keymap; -/// # use helix_term::keymap::Keymap; -/// let normal_mode = keymap!({ "Normal mode" -/// "i" => insert_mode, -/// "g" => { "Goto" -/// "g" => goto_file_start, -/// "e" => goto_file_end, -/// }, -/// "j" | "down" => move_line_down, -/// }); -/// let keymap = Keymap::new(normal_mode); -/// ``` -#[macro_export] -macro_rules! keymap { - (@trie $cmd:ident) => { - $crate::keymap::KeyTrie::Leaf($crate::commands::MappableCommand::$cmd) - }; - - (@trie - { $label:literal $(sticky=$sticky:literal)? $($($key:literal)|+ => $value:tt,)+ } - ) => { - keymap!({ $label $(sticky=$sticky)? $($($key)|+ => $value,)+ }) - }; - - (@trie [$($cmd:ident),* $(,)?]) => { - $crate::keymap::KeyTrie::Sequence(vec![$($crate::commands::Command::$cmd),*]) - }; - - ( - { $label:literal $(sticky=$sticky:literal)? $($($key:literal)|+ => $value:tt,)+ } - ) => { - // modified from the hashmap! macro - { - let _cap = hashmap!(@count $($($key),+),*); - let mut _map = ::std::collections::HashMap::with_capacity(_cap); - let mut _order = ::std::vec::Vec::with_capacity(_cap); - $( - $( - let _key = $key.parse::<::helix_view::input::KeyEvent>().unwrap(); - let _duplicate = _map.insert( - _key, - keymap!(@trie $value) - ); - assert!(_duplicate.is_none(), "Duplicate key found: {:?}", _duplicate.unwrap()); - _order.push(_key); - )+ - )* - let mut _node = $crate::keymap::KeyTrieNode::new($label, _map, _order); - $( _node.is_sticky = $sticky; )? - $crate::keymap::KeyTrie::Node(_node) - } - }; -} +use default::default; +use macros::key; #[derive(Debug, Clone)] pub struct KeyTrieNode { @@ -300,7 +188,7 @@ impl KeyTrie { } #[derive(Debug, Clone, PartialEq)] -pub enum KeymapResultKind { +pub enum KeymapResult { /// Needs more keys to execute a command. Contains valid keys for next keystroke. Pending(KeyTrieNode), Matched(MappableCommand), @@ -313,67 +201,117 @@ pub enum KeymapResultKind { Cancelled(Vec), } -/// Returned after looking up a key in [`Keymap`]. The `sticky` field has a -/// reference to the sticky node if one is currently active. -#[derive(Debug)] -pub struct KeymapResult<'a> { - pub kind: KeymapResultKind, - pub sticky: Option<&'a KeyTrieNode>, +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(transparent)] +pub struct Keymap { + /// Always a Node + root: KeyTrie, +} + +impl Keymap { + pub fn new(root: KeyTrie) -> Self { + Keymap { root } + } + + pub fn reverse_map(&self) -> HashMap>> { + // recursively visit all nodes in keymap + fn map_node( + cmd_map: &mut HashMap>>, + node: &KeyTrie, + keys: &mut Vec, + ) { + match node { + KeyTrie::Leaf(cmd) => match cmd { + MappableCommand::Typable { name, .. } => { + cmd_map.entry(name.into()).or_default().push(keys.clone()) + } + MappableCommand::Static { name, .. } => cmd_map + .entry(name.to_string()) + .or_default() + .push(keys.clone()), + }, + KeyTrie::Node(next) => { + for (key, trie) in &next.map { + keys.push(*key); + map_node(cmd_map, trie, keys); + keys.pop(); + } + } + KeyTrie::Sequence(_) => {} + }; + } + + let mut res = HashMap::new(); + map_node(&mut res, &self.root, &mut Vec::new()); + res + } + + pub fn root(&self) -> &KeyTrie { + &self.root + } + + pub fn merge(&mut self, other: Self) { + self.root.merge_nodes(other.root); + } +} + +impl Deref for Keymap { + type Target = KeyTrieNode; + + fn deref(&self) -> &Self::Target { + self.root.node().unwrap() + } } -impl<'a> KeymapResult<'a> { - pub fn new(kind: KeymapResultKind, sticky: Option<&'a KeyTrieNode>) -> Self { - Self { kind, sticky } +impl Default for Keymap { + fn default() -> Self { + Self::new(KeyTrie::Node(KeyTrieNode::default())) } } -#[derive(Debug, Clone, PartialEq, Deserialize)] -pub struct Keymap { - /// Always a Node - #[serde(flatten)] - root: KeyTrie, +pub struct Keymaps { + pub map: Box>>, /// Stores pending keys waiting for the next key. This is relative to a /// sticky node if one is in use. - #[serde(skip)] state: Vec, /// Stores the sticky node if one is activated. - #[serde(skip)] - sticky: Option, + pub sticky: Option, } -impl Keymap { - pub fn new(root: KeyTrie) -> Self { +impl Keymaps { + pub fn new(map: Box>>) -> Self { Self { - root, + map, state: Vec::new(), sticky: None, } } - pub fn root(&self) -> &KeyTrie { - &self.root - } - - pub fn sticky(&self) -> Option<&KeyTrieNode> { - self.sticky.as_ref() + pub fn map(&self) -> DynGuard> { + self.map.load() } - /// Returns list of keys waiting to be disambiguated. + /// Returns list of keys waiting to be disambiguated in current mode. pub fn pending(&self) -> &[KeyEvent] { &self.state } + pub fn sticky(&self) -> Option<&KeyTrieNode> { + self.sticky.as_ref() + } + /// Lookup `key` in the keymap to try and find a command to execute. Escape /// key cancels pending keystrokes. If there are no pending keystrokes but a /// sticky node is in use, it will be cleared. - pub fn get(&mut self, key: KeyEvent) -> KeymapResult { + pub fn get(&mut self, mode: Mode, key: KeyEvent) -> KeymapResult { + // TODO: remove the sticky part and look up manually + let keymaps = &*self.map(); + let keymap = &keymaps[&mode]; + if key!(Esc) == key { if !self.state.is_empty() { - return KeymapResult::new( - // Note that Esc is not included here - KeymapResultKind::Cancelled(self.state.drain(..).collect()), - self.sticky(), - ); + // Note that Esc is not included here + return KeymapResult::Cancelled(self.state.drain(..).collect()); } self.sticky = None; } @@ -381,20 +319,17 @@ impl Keymap { let first = self.state.get(0).unwrap_or(&key); let trie_node = match self.sticky { Some(ref trie) => Cow::Owned(KeyTrie::Node(trie.clone())), - None => Cow::Borrowed(&self.root), + None => Cow::Borrowed(&keymap.root), }; let trie = match trie_node.search(&[*first]) { Some(KeyTrie::Leaf(ref cmd)) => { - return KeymapResult::new(KeymapResultKind::Matched(cmd.clone()), self.sticky()) + return KeymapResult::Matched(cmd.clone()); } Some(KeyTrie::Sequence(ref cmds)) => { - return KeymapResult::new( - KeymapResultKind::MatchedSequence(cmds.clone()), - self.sticky(), - ) + return KeymapResult::MatchedSequence(cmds.clone()); } - None => return KeymapResult::new(KeymapResultKind::NotFound, self.sticky()), + None => return KeymapResult::NotFound, Some(t) => t, }; @@ -405,425 +340,31 @@ impl Keymap { self.state.clear(); self.sticky = Some(map.clone()); } - KeymapResult::new(KeymapResultKind::Pending(map.clone()), self.sticky()) + KeymapResult::Pending(map.clone()) } Some(&KeyTrie::Leaf(ref cmd)) => { self.state.clear(); - return KeymapResult::new(KeymapResultKind::Matched(cmd.clone()), self.sticky()); + KeymapResult::Matched(cmd.clone()) } Some(&KeyTrie::Sequence(ref cmds)) => { self.state.clear(); - KeymapResult::new( - KeymapResultKind::MatchedSequence(cmds.clone()), - self.sticky(), - ) + KeymapResult::MatchedSequence(cmds.clone()) } - None => KeymapResult::new( - KeymapResultKind::Cancelled(self.state.drain(..).collect()), - self.sticky(), - ), + None => KeymapResult::Cancelled(self.state.drain(..).collect()), } } - - pub fn merge(&mut self, other: Self) { - self.root.merge_nodes(other.root); - } -} - -impl Deref for Keymap { - type Target = KeyTrieNode; - - fn deref(&self) -> &Self::Target { - self.root.node().unwrap() - } -} - -impl Default for Keymap { - fn default() -> Self { - Self::new(KeyTrie::Node(KeyTrieNode::default())) - } -} - -#[derive(Debug, Clone, PartialEq, Deserialize)] -#[serde(transparent)] -pub struct Keymaps(pub HashMap); - -impl Keymaps { - /// Returns list of keys waiting to be disambiguated in current mode. - pub fn pending(&self) -> &[KeyEvent] { - self.0 - .values() - .find_map(|keymap| match keymap.pending().is_empty() { - true => None, - false => Some(keymap.pending()), - }) - .unwrap_or_default() - } -} - -impl Deref for Keymaps { - type Target = HashMap; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl DerefMut for Keymaps { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } } impl Default for Keymaps { fn default() -> Self { - let normal = keymap!({ "Normal mode" - "h" | "left" => move_char_left, - "j" | "down" => move_line_down, - "k" | "up" => move_line_up, - "l" | "right" => move_char_right, - - "t" => find_till_char, - "f" => find_next_char, - "T" => till_prev_char, - "F" => find_prev_char, - "r" => replace, - "R" => replace_with_yanked, - "A-." => repeat_last_motion, - - "~" => switch_case, - "`" => switch_to_lowercase, - "A-`" => switch_to_uppercase, - - "home" => goto_line_start, - "end" => goto_line_end, - - "w" => move_next_word_start, - "b" => move_prev_word_start, - "e" => move_next_word_end, - - "W" => move_next_long_word_start, - "B" => move_prev_long_word_start, - "E" => move_next_long_word_end, - - "v" => select_mode, - "G" => goto_line, - "g" => { "Goto" - "g" => goto_file_start, - "e" => goto_last_line, - "f" => goto_file, - "h" => goto_line_start, - "l" => goto_line_end, - "s" => goto_first_nonwhitespace, - "d" => goto_definition, - "y" => goto_type_definition, - "r" => goto_reference, - "i" => goto_implementation, - "t" => goto_window_top, - "c" => goto_window_center, - "b" => goto_window_bottom, - "a" => goto_last_accessed_file, - "m" => goto_last_modified_file, - "n" => goto_next_buffer, - "p" => goto_previous_buffer, - "." => goto_last_modification, - }, - ":" => command_mode, - - "i" => insert_mode, - "I" => prepend_to_line, - "a" => append_mode, - "A" => append_to_line, - "o" => open_below, - "O" => open_above, - - "d" => delete_selection, - "A-d" => delete_selection_noyank, - "c" => change_selection, - "A-c" => change_selection_noyank, - - "C" => copy_selection_on_next_line, - "A-C" => copy_selection_on_prev_line, - - - "s" => select_regex, - "A-s" => split_selection_on_newline, - "S" => split_selection, - ";" => collapse_selection, - "A-;" => flip_selections, - "A-k" => expand_selection, - "A-j" => shrink_selection, - "A-h" => select_prev_sibling, - "A-l" => select_next_sibling, - - "%" => select_all, - "x" => extend_line, - "X" => extend_to_line_bounds, - // crop_to_whole_line - - "m" => { "Match" - "m" => match_brackets, - "s" => surround_add, - "r" => surround_replace, - "d" => surround_delete, - "a" => select_textobject_around, - "i" => select_textobject_inner, - }, - "[" => { "Left bracket" - "d" => goto_prev_diag, - "D" => goto_first_diag, - "f" => goto_prev_function, - "c" => goto_prev_class, - "p" => goto_prev_parameter, - "space" => add_newline_above, - }, - "]" => { "Right bracket" - "d" => goto_next_diag, - "D" => goto_last_diag, - "f" => goto_next_function, - "c" => goto_next_class, - "p" => goto_next_parameter, - "space" => add_newline_below, - }, - - "/" => search, - "?" => rsearch, - "n" => search_next, - "N" => search_prev, - "*" => search_selection, - - "u" => undo, - "U" => redo, - "A-u" => earlier, - "A-U" => later, - - "y" => yank, - // yank_all - "p" => paste_after, - // paste_all - "P" => paste_before, - - "Q" => record_macro, - "q" => replay_macro, - - ">" => indent, - "<" => unindent, - "=" => format_selections, - "J" => join_selections, - "K" => keep_selections, - "A-K" => remove_selections, - - "," => keep_primary_selection, - "A-," => remove_primary_selection, - - // "q" => record_macro, - // "Q" => replay_macro, - - "&" => align_selections, - "_" => trim_selections, - - "(" => rotate_selections_backward, - ")" => rotate_selections_forward, - "A-(" => rotate_selection_contents_backward, - "A-)" => rotate_selection_contents_forward, - - "A-:" => ensure_selections_forward, - - "esc" => normal_mode, - "C-b" | "pageup" => page_up, - "C-f" | "pagedown" => page_down, - "C-u" => half_page_up, - "C-d" => half_page_down, - - "C-w" => { "Window" - "C-w" | "w" => rotate_view, - "C-s" | "s" => hsplit, - "C-v" | "v" => vsplit, - "f" => goto_file_hsplit, - "F" => goto_file_vsplit, - "C-q" | "q" => wclose, - "C-o" | "o" => wonly, - "C-h" | "h" | "left" => jump_view_left, - "C-j" | "j" | "down" => jump_view_down, - "C-k" | "k" | "up" => jump_view_up, - "C-l" | "l" | "right" => jump_view_right, - }, - - // move under c - "C-c" => toggle_comments, - - // z family for save/restore/combine from/to sels from register - - "tab" => jump_forward, // tab == - "C-o" => jump_backward, - "C-s" => save_selection, - - "space" => { "Space" - "f" => file_picker, - "b" => buffer_picker, - "s" => symbol_picker, - "S" => workspace_symbol_picker, - "a" => code_action, - "'" => last_picker, - "d" => { "Debug (experimental)" sticky=true - "l" => dap_launch, - "b" => dap_toggle_breakpoint, - "c" => dap_continue, - "h" => dap_pause, - "i" => dap_step_in, - "o" => dap_step_out, - "n" => dap_next, - "v" => dap_variables, - "t" => dap_terminate, - "C-c" => dap_edit_condition, - "C-l" => dap_edit_log, - "s" => { "Switch" - "t" => dap_switch_thread, - "f" => dap_switch_stack_frame, - // sl, sb - }, - "e" => dap_enable_exceptions, - "E" => dap_disable_exceptions, - }, - "w" => { "Window" - "C-w" | "w" => rotate_view, - "C-s" | "s" => hsplit, - "C-v" | "v" => vsplit, - "f" => goto_file_hsplit, - "F" => goto_file_vsplit, - "C-q" | "q" => wclose, - "C-o" | "o" => wonly, - "C-h" | "h" | "left" => jump_view_left, - "C-j" | "j" | "down" => jump_view_down, - "C-k" | "k" | "up" => jump_view_up, - "C-l" | "l" | "right" => jump_view_right, - }, - "y" => yank_joined_to_clipboard, - "Y" => yank_main_selection_to_clipboard, - "p" => paste_clipboard_after, - "P" => paste_clipboard_before, - "R" => replace_selections_with_clipboard, - "/" => global_search, - "k" => hover, - "r" => rename_symbol, - }, - "z" => { "View" - "z" | "c" => align_view_center, - "t" => align_view_top, - "b" => align_view_bottom, - "m" => align_view_middle, - "k" | "up" => scroll_up, - "j" | "down" => scroll_down, - "C-b" | "pageup" => page_up, - "C-f" | "pagedown" => page_down, - "C-u" => half_page_up, - "C-d" => half_page_down, - }, - "Z" => { "View" sticky=true - "z" | "c" => align_view_center, - "t" => align_view_top, - "b" => align_view_bottom, - "m" => align_view_middle, - "k" | "up" => scroll_up, - "j" | "down" => scroll_down, - "C-b" | "pageup" => page_up, - "C-f" | "pagedown" => page_down, - "C-u" => half_page_up, - "C-d" => half_page_down, - }, - - "\"" => select_register, - "|" => shell_pipe, - "A-|" => shell_pipe_to, - "!" => shell_insert_output, - "A-!" => shell_append_output, - "$" => shell_keep_pipe, - "C-z" => suspend, - - "C-a" => increment, - "C-x" => decrement, - }); - let mut select = normal.clone(); - select.merge_nodes(keymap!({ "Select mode" - "h" | "left" => extend_char_left, - "j" | "down" => extend_line_down, - "k" | "up" => extend_line_up, - "l" | "right" => extend_char_right, - - "w" => extend_next_word_start, - "b" => extend_prev_word_start, - "e" => extend_next_word_end, - "W" => extend_next_long_word_start, - "B" => extend_prev_long_word_start, - "E" => extend_next_long_word_end, - - "n" => extend_search_next, - "N" => extend_search_prev, - - "t" => extend_till_char, - "f" => extend_next_char, - "T" => extend_till_prev_char, - "F" => extend_prev_char, - - "home" => extend_to_line_start, - "end" => extend_to_line_end, - "esc" => exit_select_mode, - - "v" => normal_mode, - })); - let insert = keymap!({ "Insert mode" - "esc" => normal_mode, - - "backspace" => delete_char_backward, - "C-h" => delete_char_backward, - "del" => delete_char_forward, - "C-d" => delete_char_forward, - "ret" => insert_newline, - "C-j" => insert_newline, - "tab" => insert_tab, - "C-w" => delete_word_backward, - "A-backspace" => delete_word_backward, - "A-d" => delete_word_forward, - - "left" => move_char_left, - "C-b" => move_char_left, - "down" => move_line_down, - "C-n" => move_line_down, - "up" => move_line_up, - "C-p" => move_line_up, - "right" => move_char_right, - "C-f" => move_char_right, - "A-b" => move_prev_word_end, - "A-left" => move_prev_word_end, - "A-f" => move_next_word_start, - "A-right" => move_next_word_start, - "A-<" => goto_file_start, - "A->" => goto_file_end, - "pageup" => page_up, - "pagedown" => page_down, - "home" => goto_line_start, - "C-a" => goto_line_start, - "end" => goto_line_end_newline, - "C-e" => goto_line_end_newline, - - "C-k" => kill_to_line_end, - "C-u" => kill_to_line_start, - - "C-x" => completion, - "C-r" => insert_register, - }); - Self(hashmap!( - Mode::Normal => Keymap::new(normal), - Mode::Select => Keymap::new(select), - Mode::Insert => Keymap::new(insert), - )) + Self::new(Box::new(ArcSwap::new(Arc::new(default())))) } } /// Merge default config keys with user overwritten keys for custom user config. pub fn merge_keys(mut config: Config) -> Config { - let mut delta = std::mem::take(&mut config.keys); - for (mode, keys) in &mut *config.keys { + let mut delta = std::mem::replace(&mut config.keys, default()); + for (mode, keys) in &mut config.keys { keys.merge(delta.remove(mode).unwrap_or_default()) } config @@ -831,7 +372,10 @@ pub fn merge_keys(mut config: Config) -> Config { #[cfg(test)] mod tests { + use super::macros::keymap; use super::*; + use arc_swap::access::Constant; + use helix_core::hashmap; #[test] #[should_panic] @@ -851,7 +395,7 @@ mod tests { #[test] fn merge_partial_keys() { let config = Config { - keys: Keymaps(hashmap! { + keys: hashmap! { Mode::Normal => Keymap::new( keymap!({ "Normal mode" "i" => normal_mode, @@ -863,29 +407,31 @@ mod tests { }, }) ) - }), + }, ..Default::default() }; let mut merged_config = merge_keys(config.clone()); assert_ne!(config, merged_config); - let keymap = merged_config.keys.0.get_mut(&Mode::Normal).unwrap(); + let mut keymap = Keymaps::new(Box::new(Constant(merged_config.keys.clone()))); assert_eq!( - keymap.get(key!('i')).kind, - KeymapResultKind::Matched(MappableCommand::normal_mode), + keymap.get(Mode::Normal, key!('i')), + KeymapResult::Matched(MappableCommand::normal_mode), "Leaf should replace leaf" ); assert_eq!( - keymap.get(key!('无')).kind, - KeymapResultKind::Matched(MappableCommand::insert_mode), + keymap.get(Mode::Normal, key!('无')), + KeymapResult::Matched(MappableCommand::insert_mode), "New leaf should be present in merged keymap" ); // Assumes that z is a node in the default keymap assert_eq!( - keymap.get(key!('z')).kind, - KeymapResultKind::Matched(MappableCommand::jump_backward), + keymap.get(Mode::Normal, key!('z')), + KeymapResult::Matched(MappableCommand::jump_backward), "Leaf should replace node" ); + + let keymap = merged_config.keys.get_mut(&Mode::Normal).unwrap(); // Assumes that `g` is a node in default keymap assert_eq!( keymap.root().search(&[key!('g'), key!('$')]).unwrap(), @@ -905,14 +451,14 @@ mod tests { "Old leaves in subnode should be present in merged node" ); - assert!(merged_config.keys.0.get(&Mode::Normal).unwrap().len() > 1); - assert!(merged_config.keys.0.get(&Mode::Insert).unwrap().len() > 0); + assert!(merged_config.keys.get(&Mode::Normal).unwrap().len() > 1); + assert!(merged_config.keys.get(&Mode::Insert).unwrap().len() > 0); } #[test] fn order_should_be_set() { let config = Config { - keys: Keymaps(hashmap! { + keys: hashmap! { Mode::Normal => Keymap::new( keymap!({ "Normal mode" "space" => { "" @@ -923,12 +469,12 @@ mod tests { }, }) ) - }), + }, ..Default::default() }; let mut merged_config = merge_keys(config.clone()); assert_ne!(config, merged_config); - let keymap = merged_config.keys.0.get_mut(&Mode::Normal).unwrap(); + let keymap = merged_config.keys.get_mut(&Mode::Normal).unwrap(); // Make sure mapping works assert_eq!( keymap @@ -945,7 +491,7 @@ mod tests { #[test] fn aliased_modes_are_same_in_default_keymap() { - let keymaps = Keymaps::default(); + let keymaps = Keymaps::default().map(); let root = keymaps.get(&Mode::Normal).unwrap().root(); assert_eq!( root.search(&[key!(' '), key!('w')]).unwrap(), @@ -958,4 +504,45 @@ mod tests { "Mismatch for view mode on `z` and `Z`" ); } + + #[test] + fn reverse_map() { + let normal_mode = keymap!({ "Normal mode" + "i" => insert_mode, + "g" => { "Goto" + "g" => goto_file_start, + "e" => goto_file_end, + }, + "j" | "k" => move_line_down, + }); + let keymap = Keymap::new(normal_mode); + let mut reverse_map = keymap.reverse_map(); + + // sort keybindings in order to have consistent tests + // HashMaps can be compared but we can still get different ordering of bindings + // for commands that have multiple bindings assigned + for v in reverse_map.values_mut() { + v.sort() + } + + assert_eq!( + reverse_map, + HashMap::from([ + ("insert_mode".to_string(), vec![vec![key!('i')]]), + ( + "goto_file_start".to_string(), + vec![vec![key!('g'), key!('g')]] + ), + ( + "goto_file_end".to_string(), + vec![vec![key!('g'), key!('e')]] + ), + ( + "move_line_down".to_string(), + vec![vec![key!('j')], vec![key!('k')]] + ), + ]), + "Mistmatch" + ) + } } diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs new file mode 100644 index 000000000..b5685082c --- /dev/null +++ b/helix-term/src/keymap/default.rs @@ -0,0 +1,359 @@ +use std::collections::HashMap; + +use super::macros::keymap; +use super::{Keymap, Mode}; +use helix_core::hashmap; + +pub fn default() -> HashMap { + let normal = keymap!({ "Normal mode" + "h" | "left" => move_char_left, + "j" | "down" => move_line_down, + "k" | "up" => move_line_up, + "l" | "right" => move_char_right, + + "t" => find_till_char, + "f" => find_next_char, + "T" => till_prev_char, + "F" => find_prev_char, + "r" => replace, + "R" => replace_with_yanked, + "A-." => repeat_last_motion, + + "~" => switch_case, + "`" => switch_to_lowercase, + "A-`" => switch_to_uppercase, + + "home" => goto_line_start, + "end" => goto_line_end, + + "w" => move_next_word_start, + "b" => move_prev_word_start, + "e" => move_next_word_end, + + "W" => move_next_long_word_start, + "B" => move_prev_long_word_start, + "E" => move_next_long_word_end, + + "v" => select_mode, + "G" => goto_line, + "g" => { "Goto" + "g" => goto_file_start, + "e" => goto_last_line, + "f" => goto_file, + "h" => goto_line_start, + "l" => goto_line_end, + "s" => goto_first_nonwhitespace, + "d" => goto_definition, + "y" => goto_type_definition, + "r" => goto_reference, + "i" => goto_implementation, + "t" => goto_window_top, + "c" => goto_window_center, + "b" => goto_window_bottom, + "a" => goto_last_accessed_file, + "m" => goto_last_modified_file, + "n" => goto_next_buffer, + "p" => goto_previous_buffer, + "." => goto_last_modification, + }, + ":" => command_mode, + + "i" => insert_mode, + "I" => prepend_to_line, + "a" => append_mode, + "A" => append_to_line, + "o" => open_below, + "O" => open_above, + + "d" => delete_selection, + "A-d" => delete_selection_noyank, + "c" => change_selection, + "A-c" => change_selection_noyank, + + "C" => copy_selection_on_next_line, + "A-C" => copy_selection_on_prev_line, + + + "s" => select_regex, + "A-s" => split_selection_on_newline, + "S" => split_selection, + ";" => collapse_selection, + "A-;" => flip_selections, + "A-k" | "A-up" => expand_selection, + "A-j" | "A-down" => shrink_selection, + "A-h" | "A-left" => select_prev_sibling, + "A-l" | "A-right" => select_next_sibling, + + "%" => select_all, + "x" => extend_line, + "X" => extend_to_line_bounds, + // crop_to_whole_line + + "m" => { "Match" + "m" => match_brackets, + "s" => surround_add, + "r" => surround_replace, + "d" => surround_delete, + "a" => select_textobject_around, + "i" => select_textobject_inner, + }, + "[" => { "Left bracket" + "d" => goto_prev_diag, + "D" => goto_first_diag, + "f" => goto_prev_function, + "c" => goto_prev_class, + "a" => goto_prev_parameter, + "o" => goto_prev_comment, + "space" => add_newline_above, + }, + "]" => { "Right bracket" + "d" => goto_next_diag, + "D" => goto_last_diag, + "f" => goto_next_function, + "c" => goto_next_class, + "a" => goto_next_parameter, + "o" => goto_next_comment, + "space" => add_newline_below, + }, + + "/" => search, + "?" => rsearch, + "n" => search_next, + "N" => search_prev, + "*" => search_selection, + + "u" => undo, + "U" => redo, + "A-u" => earlier, + "A-U" => later, + + "y" => yank, + // yank_all + "p" => paste_after, + // paste_all + "P" => paste_before, + + "Q" => record_macro, + "q" => replay_macro, + + ">" => indent, + "<" => unindent, + "=" => format_selections, + "J" => join_selections, + "K" => keep_selections, + "A-K" => remove_selections, + + "," => keep_primary_selection, + "A-," => remove_primary_selection, + + // "q" => record_macro, + // "Q" => replay_macro, + + "&" => align_selections, + "_" => trim_selections, + + "(" => rotate_selections_backward, + ")" => rotate_selections_forward, + "A-(" => rotate_selection_contents_backward, + "A-)" => rotate_selection_contents_forward, + + "A-:" => ensure_selections_forward, + + "esc" => normal_mode, + "C-b" | "pageup" => page_up, + "C-f" | "pagedown" => page_down, + "C-u" => half_page_up, + "C-d" => half_page_down, + + "C-w" => { "Window" + "C-w" | "w" => rotate_view, + "C-s" | "s" => hsplit, + "C-v" | "v" => vsplit, + "f" => goto_file_hsplit, + "F" => goto_file_vsplit, + "C-q" | "q" => wclose, + "C-o" | "o" => wonly, + "C-h" | "h" | "left" => jump_view_left, + "C-j" | "j" | "down" => jump_view_down, + "C-k" | "k" | "up" => jump_view_up, + "C-l" | "l" | "right" => jump_view_right, + "n" => { "New split scratch buffer" + "C-s" | "s" => hsplit_new, + "C-v" | "v" => vsplit_new, + }, + }, + + // move under c + "C-c" => toggle_comments, + + // z family for save/restore/combine from/to sels from register + + "tab" => jump_forward, // tab == + "C-o" => jump_backward, + "C-s" => save_selection, + + "space" => { "Space" + "f" => file_picker, + "b" => buffer_picker, + "s" => symbol_picker, + "S" => workspace_symbol_picker, + "a" => code_action, + "'" => last_picker, + "d" => { "Debug (experimental)" sticky=true + "l" => dap_launch, + "b" => dap_toggle_breakpoint, + "c" => dap_continue, + "h" => dap_pause, + "i" => dap_step_in, + "o" => dap_step_out, + "n" => dap_next, + "v" => dap_variables, + "t" => dap_terminate, + "C-c" => dap_edit_condition, + "C-l" => dap_edit_log, + "s" => { "Switch" + "t" => dap_switch_thread, + "f" => dap_switch_stack_frame, + // sl, sb + }, + "e" => dap_enable_exceptions, + "E" => dap_disable_exceptions, + }, + "w" => { "Window" + "C-w" | "w" => rotate_view, + "C-s" | "s" => hsplit, + "C-v" | "v" => vsplit, + "f" => goto_file_hsplit, + "F" => goto_file_vsplit, + "C-q" | "q" => wclose, + "C-o" | "o" => wonly, + "C-h" | "h" | "left" => jump_view_left, + "C-j" | "j" | "down" => jump_view_down, + "C-k" | "k" | "up" => jump_view_up, + "C-l" | "l" | "right" => jump_view_right, + "n" => { "New split scratch buffer" + "C-s" | "s" => hsplit_new, + "C-v" | "v" => vsplit_new, + }, + }, + "y" => yank_joined_to_clipboard, + "Y" => yank_main_selection_to_clipboard, + "p" => paste_clipboard_after, + "P" => paste_clipboard_before, + "R" => replace_selections_with_clipboard, + "/" => global_search, + "k" => hover, + "r" => rename_symbol, + "?" => command_palette, + }, + "z" => { "View" + "z" | "c" => align_view_center, + "t" => align_view_top, + "b" => align_view_bottom, + "m" => align_view_middle, + "k" | "up" => scroll_up, + "j" | "down" => scroll_down, + "C-b" | "pageup" => page_up, + "C-f" | "pagedown" => page_down, + "C-u" => half_page_up, + "C-d" => half_page_down, + }, + "Z" => { "View" sticky=true + "z" | "c" => align_view_center, + "t" => align_view_top, + "b" => align_view_bottom, + "m" => align_view_middle, + "k" | "up" => scroll_up, + "j" | "down" => scroll_down, + "C-b" | "pageup" => page_up, + "C-f" | "pagedown" => page_down, + "C-u" => half_page_up, + "C-d" => half_page_down, + }, + + "\"" => select_register, + "|" => shell_pipe, + "A-|" => shell_pipe_to, + "!" => shell_insert_output, + "A-!" => shell_append_output, + "$" => shell_keep_pipe, + "C-z" => suspend, + + "C-a" => increment, + "C-x" => decrement, + }); + let mut select = normal.clone(); + select.merge_nodes(keymap!({ "Select mode" + "h" | "left" => extend_char_left, + "j" | "down" => extend_line_down, + "k" | "up" => extend_line_up, + "l" | "right" => extend_char_right, + + "w" => extend_next_word_start, + "b" => extend_prev_word_start, + "e" => extend_next_word_end, + "W" => extend_next_long_word_start, + "B" => extend_prev_long_word_start, + "E" => extend_next_long_word_end, + + "n" => extend_search_next, + "N" => extend_search_prev, + + "t" => extend_till_char, + "f" => extend_next_char, + "T" => extend_till_prev_char, + "F" => extend_prev_char, + + "home" => extend_to_line_start, + "end" => extend_to_line_end, + "esc" => exit_select_mode, + + "v" => normal_mode, + })); + let insert = keymap!({ "Insert mode" + "esc" => normal_mode, + + "backspace" => delete_char_backward, + "C-h" => delete_char_backward, + "del" => delete_char_forward, + "C-d" => delete_char_forward, + "ret" => insert_newline, + "C-j" => insert_newline, + "tab" => insert_tab, + "C-w" => delete_word_backward, + "A-backspace" => delete_word_backward, + "A-d" => delete_word_forward, + + "left" => move_char_left, + "C-b" => move_char_left, + "down" => move_line_down, + "C-n" => move_line_down, + "up" => move_line_up, + "C-p" => move_line_up, + "right" => move_char_right, + "C-f" => move_char_right, + "A-b" => move_prev_word_end, + "A-left" => move_prev_word_end, + "A-f" => move_next_word_start, + "A-right" => move_next_word_start, + "A-<" => goto_file_start, + "A->" => goto_file_end, + "pageup" => page_up, + "pagedown" => page_down, + "home" => goto_line_start, + "C-a" => goto_line_start, + "end" => goto_line_end_newline, + "C-e" => goto_line_end_newline, + + "C-k" => kill_to_line_end, + "C-u" => kill_to_line_start, + + "C-x" => completion, + "C-r" => insert_register, + }); + hashmap!( + Mode::Normal => Keymap::new(normal), + Mode::Select => Keymap::new(select), + Mode::Insert => Keymap::new(insert), + ) +} diff --git a/helix-term/src/keymap/macros.rs b/helix-term/src/keymap/macros.rs new file mode 100644 index 000000000..c4a1bfbb3 --- /dev/null +++ b/helix-term/src/keymap/macros.rs @@ -0,0 +1,127 @@ +#[macro_export] +macro_rules! key { + ($key:ident) => { + ::helix_view::input::KeyEvent { + code: ::helix_view::keyboard::KeyCode::$key, + modifiers: ::helix_view::keyboard::KeyModifiers::NONE, + } + }; + ($($ch:tt)*) => { + ::helix_view::input::KeyEvent { + code: ::helix_view::keyboard::KeyCode::Char($($ch)*), + modifiers: ::helix_view::keyboard::KeyModifiers::NONE, + } + }; +} + +#[macro_export] +macro_rules! shift { + ($key:ident) => { + ::helix_view::input::KeyEvent { + code: ::helix_view::keyboard::KeyCode::$key, + modifiers: ::helix_view::keyboard::KeyModifiers::SHIFT, + } + }; + ($($ch:tt)*) => { + ::helix_view::input::KeyEvent { + code: ::helix_view::keyboard::KeyCode::Char($($ch)*), + modifiers: ::helix_view::keyboard::KeyModifiers::SHIFT, + } + }; +} + +#[macro_export] +macro_rules! ctrl { + ($key:ident) => { + ::helix_view::input::KeyEvent { + code: ::helix_view::keyboard::KeyCode::$key, + modifiers: ::helix_view::keyboard::KeyModifiers::CONTROL, + } + }; + ($($ch:tt)*) => { + ::helix_view::input::KeyEvent { + code: ::helix_view::keyboard::KeyCode::Char($($ch)*), + modifiers: ::helix_view::keyboard::KeyModifiers::CONTROL, + } + }; +} + +#[macro_export] +macro_rules! alt { + ($key:ident) => { + ::helix_view::input::KeyEvent { + code: ::helix_view::keyboard::KeyCode::$key, + modifiers: ::helix_view::keyboard::KeyModifiers::ALT, + } + }; + ($($ch:tt)*) => { + ::helix_view::input::KeyEvent { + code: ::helix_view::keyboard::KeyCode::Char($($ch)*), + modifiers: ::helix_view::keyboard::KeyModifiers::ALT, + } + }; +} + +/// Macro for defining the root of a `Keymap` object. Example: +/// +/// ``` +/// # use helix_core::hashmap; +/// # use helix_term::keymap; +/// # use helix_term::keymap::Keymap; +/// let normal_mode = keymap!({ "Normal mode" +/// "i" => insert_mode, +/// "g" => { "Goto" +/// "g" => goto_file_start, +/// "e" => goto_file_end, +/// }, +/// "j" | "down" => move_line_down, +/// }); +/// let keymap = Keymap::new(normal_mode); +/// ``` +#[macro_export] +macro_rules! keymap { + (@trie $cmd:ident) => { + $crate::keymap::KeyTrie::Leaf($crate::commands::MappableCommand::$cmd) + }; + + (@trie + { $label:literal $(sticky=$sticky:literal)? $($($key:literal)|+ => $value:tt,)+ } + ) => { + keymap!({ $label $(sticky=$sticky)? $($($key)|+ => $value,)+ }) + }; + + (@trie [$($cmd:ident),* $(,)?]) => { + $crate::keymap::KeyTrie::Sequence(vec![$($crate::commands::Command::$cmd),*]) + }; + + ( + { $label:literal $(sticky=$sticky:literal)? $($($key:literal)|+ => $value:tt,)+ } + ) => { + // modified from the hashmap! macro + { + let _cap = hashmap!(@count $($($key),+),*); + let mut _map = ::std::collections::HashMap::with_capacity(_cap); + let mut _order = ::std::vec::Vec::with_capacity(_cap); + $( + $( + let _key = $key.parse::<::helix_view::input::KeyEvent>().unwrap(); + let _duplicate = _map.insert( + _key, + keymap!(@trie $value) + ); + assert!(_duplicate.is_none(), "Duplicate key found: {:?}", _duplicate.unwrap()); + _order.push(_key); + )+ + )* + let mut _node = $crate::keymap::KeyTrieNode::new($label, _map, _order); + $( _node.is_sticky = $sticky; )? + $crate::keymap::KeyTrie::Node(_node) + } + }; +} + +pub use alt; +pub use ctrl; +pub use key; +pub use keymap; +pub use shift; diff --git a/helix-term/src/lib.rs b/helix-term/src/lib.rs index 58cb139c7..a945b20de 100644 --- a/helix-term/src/lib.rs +++ b/helix-term/src/lib.rs @@ -6,9 +6,11 @@ pub mod args; pub mod commands; pub mod compositor; pub mod config; +pub mod health; pub mod job; pub mod keymap; pub mod ui; +pub use keymap::macros::*; #[cfg(not(windows))] fn true_color() -> bool { diff --git a/helix-term/src/main.rs b/helix-term/src/main.rs index 0f504046f..4a3434d1f 100644 --- a/helix-term/src/main.rs +++ b/helix-term/src/main.rs @@ -1,8 +1,7 @@ use anyhow::{Context, Error, Result}; use helix_term::application::Application; use helix_term::args::Args; -use helix_term::config::Config; -use helix_term::keymap::merge_keys; +use helix_term::config::{Config, ConfigLoadError}; use std::path::PathBuf; fn setup_logging(logpath: PathBuf, verbosity: u64) -> Result<()> { @@ -40,12 +39,12 @@ fn main() -> Result<()> { #[tokio::main] async fn main_impl() -> Result { - let cache_dir = helix_core::cache_dir(); - if !cache_dir.exists() { - std::fs::create_dir_all(&cache_dir).ok(); + let logpath = helix_loader::log_file(); + let parent = logpath.parent().unwrap(); + if !parent.exists() { + std::fs::create_dir_all(parent).ok(); } - let logpath = cache_dir.join("helix.log"); let help = format!( "\ {} {} @@ -59,11 +58,14 @@ ARGS: ... Sets the input file to use, position can also be specified via file[:row[:col]] FLAGS: - -h, --help Prints help information - --tutor Loads the tutorial - -v Increases logging verbosity each use for up to 3 times - (default file: {}) - -V, --version Prints version information + -h, --help Prints help information + --tutor Loads the tutorial + --health [LANG] Checks for potential errors in editor setup + If given, checks for config errors in language LANG + -g, --grammar {{fetch|build}} Fetches or builds tree-sitter grammars listed in languages.toml + -v Increases logging verbosity each use for up to 3 times + (default file: {}) + -V, --version Prints version information ", env!("CARGO_PKG_NAME"), env!("VERSION_AND_GIT_HASH"), @@ -85,24 +87,51 @@ FLAGS: std::process::exit(0); } - let conf_dir = helix_core::config_dir(); + if args.health { + if let Err(err) = helix_term::health::print_health(args.health_arg) { + // Piping to for example `head -10` requires special handling: + // https://stackoverflow.com/a/65760807/7115678 + if err.kind() != std::io::ErrorKind::BrokenPipe { + return Err(err.into()); + } + } + + std::process::exit(0); + } + + if args.fetch_grammars { + helix_loader::grammar::fetch_grammars()?; + return Ok(0); + } + + if args.build_grammars { + helix_loader::grammar::build_grammars()?; + return Ok(0); + } + + let conf_dir = helix_loader::config_dir(); if !conf_dir.exists() { std::fs::create_dir_all(&conf_dir).ok(); } - let config = match std::fs::read_to_string(conf_dir.join("config.toml")) { - Ok(config) => toml::from_str(&config) - .map(merge_keys) - .unwrap_or_else(|err| { - eprintln!("Bad config: {}", err); - eprintln!("Press to continue with default config"); - use std::io::Read; - // This waits for an enter press. - let _ = std::io::stdin().read(&mut []); - Config::default() - }), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Config::default(), - Err(err) => return Err(Error::new(err)), + let config = match Config::load_default() { + Ok(config) => config, + Err(err) => { + match err { + ConfigLoadError::BadConfig(err) => { + eprintln!("Bad config: {}", err); + eprintln!("Press to continue with default config"); + use std::io::Read; + // This waits for an enter press. + let _ = std::io::stdin().read(&mut []); + Config::default() + } + ConfigLoadError::Error(err) if err.kind() == std::io::ErrorKind::NotFound => { + Config::default() + } + ConfigLoadError::Error(err) => return Err(Error::new(err)), + } + } }; setup_logging(logpath, args.verbosity).context("failed to initialize logging")?; diff --git a/helix-term/src/ui/completion.rs b/helix-term/src/ui/completion.rs index 35afe81e9..1ee4a01a9 100644 --- a/helix-term/src/ui/completion.rs +++ b/helix-term/src/ui/completion.rs @@ -1,10 +1,11 @@ use crate::compositor::{Component, Context, EventResult}; use crossterm::event::{Event, KeyCode, KeyEvent}; +use helix_view::editor::CompleteAction; use tui::buffer::Buffer as Surface; use std::borrow::Cow; -use helix_core::Transaction; +use helix_core::{Change, Transaction}; use helix_view::{graphics::Rect, Document, Editor}; use crate::commands; @@ -92,13 +93,14 @@ impl Completion { start_offset: usize, trigger_offset: usize, ) -> Transaction { - if let Some(edit) = &item.text_edit { + let transaction = if let Some(edit) = &item.text_edit { let edit = match edit { lsp::CompletionTextEdit::Edit(edit) => edit.clone(), lsp::CompletionTextEdit::InsertAndReplace(item) => { unimplemented!("completion: insert_and_replace {:?}", item) } }; + util::generate_transaction_from_edits( doc.text(), vec![edit], @@ -114,7 +116,16 @@ impl Completion { doc.text(), vec![(trigger_offset, trigger_offset, Some(text.into()))].into_iter(), ) - } + }; + + transaction + } + + fn completion_changes(transaction: &Transaction, trigger_offset: usize) -> Vec { + transaction + .changes_iter() + .filter(|(start, end, _)| (*start..=*end).contains(&trigger_offset)) + .collect() } let (view, doc) = current!(editor); @@ -123,7 +134,9 @@ impl Completion { doc.restore(view.id); match event { - PromptEvent::Abort => {} + PromptEvent::Abort => { + editor.last_completion = None; + } PromptEvent::Update => { // always present here let item = item.unwrap(); @@ -138,8 +151,12 @@ impl Completion { // initialize a savepoint doc.savepoint(); - doc.apply(&transaction, view.id); + + editor.last_completion = Some(CompleteAction { + trigger_offset, + changes: completion_changes(&transaction, trigger_offset), + }); } PromptEvent::Validate => { // always present here @@ -152,8 +169,14 @@ impl Completion { start_offset, trigger_offset, ); + doc.apply(&transaction, view.id); + editor.last_completion = Some(CompleteAction { + trigger_offset, + changes: completion_changes(&transaction, trigger_offset), + }); + // apply additional edits, mostly used to auto import unqualified types let resolved_additional_text_edits = if item.additional_text_edits.is_some() { None @@ -165,7 +188,7 @@ impl Completion { if let Some(additional_edits) = item .additional_text_edits .as_ref() - .or_else(|| resolved_additional_text_edits.as_ref()) + .or(resolved_additional_text_edits.as_ref()) { if !additional_edits.is_empty() { let transaction = util::generate_transaction_from_edits( @@ -257,18 +280,6 @@ impl Completion { } } -// need to: -// - trigger on the right trigger char -// - detect previous open instance and recycle -// - update after input, but AFTER the document has changed -// - if no more matches, need to auto close -// -// missing bits: -// - a more robust hook system: emit to a channel, process in main loop -// - a way to find specific layers in compositor -// - components register for hooks, then unregister when terminated -// ... since completion is a special case, maybe just build it into doc/render? - impl Component for Completion { fn handle_event(&mut self, event: Event, cx: &mut Context) -> EventResult { // let the Editor handle Esc instead @@ -276,7 +287,7 @@ impl Component for Completion { code: KeyCode::Esc, .. }) = event { - return EventResult::Ignored; + return EventResult::Ignored(None); } self.popup.handle_event(event, cx) } @@ -305,8 +316,6 @@ impl Component for Completion { let coords = helix_core::visual_coords_at_pos(text, cursor_pos, doc.tab_width()); let cursor_pos = (coords.row - view.offset.row) as u16; - let markdown_ui = - |content, syn_loader| Markdown::new(content, syn_loader).style_group("completion"); let mut markdown_doc = match &option.documentation { Some(lsp::Documentation::String(contents)) | Some(lsp::Documentation::MarkupContent(lsp::MarkupContent { @@ -314,7 +323,7 @@ impl Component for Completion { value: contents, })) => { // TODO: convert to wrapped text - markdown_ui( + Markdown::new( format!( "```{}\n{}\n```\n{}", language, @@ -329,7 +338,7 @@ impl Component for Completion { value: contents, })) => { // TODO: set language based on doc scope - markdown_ui( + Markdown::new( format!( "```{}\n{}\n```\n{}", language, @@ -343,7 +352,7 @@ impl Component for Completion { // TODO: copied from above // TODO: set language based on doc scope - markdown_ui( + Markdown::new( format!( "```{}\n{}\n```", language, diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index fc749ebb8..28665ec3b 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -2,7 +2,7 @@ use crate::{ commands, compositor::{Component, Context, EventResult}, key, - keymap::{KeymapResult, KeymapResultKind, Keymaps}, + keymap::{KeymapResult, Keymaps}, ui::{Completion, ProgressSpinners}, }; @@ -15,11 +15,11 @@ use helix_core::{ syntax::{self, HighlightEvent}, unicode::segmentation::UnicodeSegmentation, unicode::width::UnicodeWidthStr, - LineEnding, Position, Range, Selection, + LineEnding, Position, Range, Selection, Transaction, }; use helix_view::{ document::{Mode, SCRATCH_BUFFER_NAME}, - editor::CursorShapeConfig, + editor::{CompleteAction, CursorShapeConfig}, graphics::{CursorKind, Modifier, Rect, Style}, input::KeyEvent, keyboard::{KeyCode, KeyModifiers}, @@ -33,11 +33,18 @@ use tui::buffer::Buffer as Surface; pub struct EditorView { pub keymaps: Keymaps, on_next_key: Option>, - last_insert: (commands::MappableCommand, Vec), + last_insert: (commands::MappableCommand, Vec), pub(crate) completion: Option, spinners: ProgressSpinners, } +#[derive(Debug, Clone)] +pub enum InsertEvent { + Key(KeyEvent), + CompletionApply(CompleteAction), + TriggerCompletion, +} + impl Default for EditorView { fn default() -> Self { Self::new(Keymaps::default()) @@ -111,7 +118,7 @@ impl EditorView { let highlights: Box> = if is_focused { Box::new(syntax::merge( highlights, - Self::doc_selection_highlights(doc, view, theme, &editor.config.cursor_shape), + Self::doc_selection_highlights(doc, view, theme, &editor.config().cursor_shape), )) } else { Box::new(highlights) @@ -176,6 +183,7 @@ impl EditorView { .highlight_iter(text.slice(..), Some(range), None) .map(|event| event.unwrap()) .map(move |event| match event { + // TODO: use byte slices directly // convert byte offsets to char offset HighlightEvent::Source { start, end } => { let start = @@ -310,6 +318,8 @@ impl EditorView { theme: &Theme, highlights: H, ) { + // It's slightly more efficient to produce a full RopeSlice from the Rope, then slice that a bunch + // of times than it is to always call Rope::slice/get_slice (it will internally always hit RSEnum::Light). let text = doc.text().slice(..); let mut spans = Vec::new(); @@ -320,10 +330,6 @@ impl EditorView { let text_style = theme.get("ui.text"); - // It's slightly more efficient to produce a full RopeSlice from the Rope, then slice that a bunch - // of times than it is to always call Rope::slice/get_slice (it will internally always hit RSEnum::Light). - let text = text.slice(..); - 'outer: for event in highlights { match event { HighlightEvent::HighlightStart(span) => { @@ -371,7 +377,8 @@ impl EditorView { let (grapheme, width) = if grapheme == "\t" { // make sure we display tab as appropriate amount of spaces - (tab.as_str(), tab_width) + let visual_tab_width = tab_width - (visual_x as usize % tab_width); + (&tab[..visual_tab_width], visual_tab_width) } else { // Cow will prevent allocations if span contained in a single slice // which should really be the majority case @@ -515,7 +522,6 @@ impl EditorView { let info = theme.get("info"); let hint = theme.get("hint"); - // Vec::with_capacity(diagnostics.len()); // rough estimate let mut lines = Vec::new(); for diagnostic in diagnostics { let text = Text::styled( @@ -630,19 +636,6 @@ impl EditorView { base_style, )); - // let indent_info = match doc.indent_style { - // IndentStyle::Tabs => "tabs", - // IndentStyle::Spaces(1) => "spaces:1", - // IndentStyle::Spaces(2) => "spaces:2", - // IndentStyle::Spaces(3) => "spaces:3", - // IndentStyle::Spaces(4) => "spaces:4", - // IndentStyle::Spaces(5) => "spaces:5", - // IndentStyle::Spaces(6) => "spaces:6", - // IndentStyle::Spaces(7) => "spaces:7", - // IndentStyle::Spaces(8) => "spaces:8", - // _ => "indent:ERROR", - // }; - // Position let pos = coords_at_pos( doc.text().slice(..), @@ -688,12 +681,12 @@ impl EditorView { surface.set_string_truncated( viewport.x + 8, // 8: 1 space + 3 char mode string + 1 space + 1 spinner + 1 space viewport.y, - title, + &title, viewport .width .saturating_sub(6) .saturating_sub(right_side_text.width() as u16 + 1) as usize, // "+ 1": a space between the title and the selection info - base_style, + |_| base_style, true, true, ); @@ -701,7 +694,7 @@ impl EditorView { /// Handle events by looking them up in `self.keymaps`. Returns None /// if event was handled (a command was executed or a subkeymap was - /// activated). Only KeymapResultKind::{NotFound, Cancelled} is returned + /// activated). Only KeymapResult::{NotFound, Cancelled} is returned /// otherwise. fn handle_keymap_event( &mut self, @@ -709,38 +702,37 @@ impl EditorView { cxt: &mut commands::Context, event: KeyEvent, ) -> Option { - cxt.editor.autoinfo = None; - let key_result = self.keymaps.get_mut(&mode).unwrap().get(event); - cxt.editor.autoinfo = key_result.sticky.map(|node| node.infobox()); - - match &key_result.kind { - KeymapResultKind::Matched(command) => command.execute(cxt), - KeymapResultKind::Pending(node) => cxt.editor.autoinfo = Some(node.infobox()), - KeymapResultKind::MatchedSequence(commands) => { + let key_result = self.keymaps.get(mode, event); + cxt.editor.autoinfo = self.keymaps.sticky().map(|node| node.infobox()); + + match &key_result { + KeymapResult::Matched(command) => command.execute(cxt), + KeymapResult::Pending(node) => cxt.editor.autoinfo = Some(node.infobox()), + KeymapResult::MatchedSequence(commands) => { for command in commands { command.execute(cxt); } } - KeymapResultKind::NotFound | KeymapResultKind::Cancelled(_) => return Some(key_result), + KeymapResult::NotFound | KeymapResult::Cancelled(_) => return Some(key_result), } None } fn insert_mode(&mut self, cx: &mut commands::Context, event: KeyEvent) { if let Some(keyresult) = self.handle_keymap_event(Mode::Insert, cx, event) { - match keyresult.kind { - KeymapResultKind::NotFound => { + match keyresult { + KeymapResult::NotFound => { if let Some(ch) = event.char() { commands::insert::insert_char(cx, ch) } } - KeymapResultKind::Cancelled(pending) => { + KeymapResult::Cancelled(pending) => { for ev in pending { match ev.char() { Some(ch) => commands::insert::insert_char(cx, ch), None => { - if let KeymapResultKind::Matched(command) = - self.keymaps.get_mut(&Mode::Insert).unwrap().get(ev).kind + if let KeymapResult::Matched(command) = + self.keymaps.get(Mode::Insert, ev) { command.execute(cx); } @@ -766,8 +758,33 @@ impl EditorView { // first execute whatever put us into insert mode self.last_insert.0.execute(cxt); // then replay the inputs - for &key in &self.last_insert.1.clone() { - self.insert_mode(cxt, key) + for key in self.last_insert.1.clone() { + match key { + InsertEvent::Key(key) => self.insert_mode(cxt, key), + InsertEvent::CompletionApply(compl) => { + let (view, doc) = current!(cxt.editor); + + doc.restore(view.id); + + let text = doc.text().slice(..); + let cursor = doc.selection(view.id).primary().cursor(text); + + let shift_position = + |pos: usize| -> usize { pos + cursor - compl.trigger_offset }; + + let tx = Transaction::change( + doc.text(), + compl.changes.iter().cloned().map(|(start, end, t)| { + (shift_position(start), shift_position(end), t) + }), + ); + doc.apply(&tx, view.id); + } + InsertEvent::TriggerCompletion => { + let (_, doc) = current!(cxt.editor); + doc.savepoint(); + } + } } } _ => { @@ -808,6 +825,9 @@ impl EditorView { // Immediately initialize a savepoint doc_mut!(editor).savepoint(); + editor.last_completion = None; + self.last_insert.1.push(InsertEvent::TriggerCompletion); + // TODO : propagate required size on resize to completion too completion.required_size((size.width, size.height)); self.completion = Some(completion); @@ -821,6 +841,27 @@ impl EditorView { doc.savepoint = None; editor.clear_idle_timer(); // don't retrigger } + + pub fn handle_idle_timeout(&mut self, cx: &mut crate::compositor::Context) -> EventResult { + if self.completion.is_some() + || !cx.editor.config().auto_completion + || doc!(cx.editor).mode != Mode::Insert + { + return EventResult::Ignored(None); + } + + let mut cx = commands::Context { + register: None, + editor: cx.editor, + jobs: cx.jobs, + count: None, + callback: None, + on_next_key_callback: None, + }; + crate::commands::insert::idle_completion(&mut cx); + + EventResult::Consumed(None) + } } impl EditorView { @@ -829,6 +870,7 @@ impl EditorView { event: MouseEvent, cxt: &mut commands::Context, ) -> EventResult { + let config = cxt.editor.config(); match event { MouseEvent { kind: MouseEventKind::Down(MouseButton::Left), @@ -872,9 +914,7 @@ impl EditorView { let path = match doc.path() { Some(path) => path.clone(), - None => { - return EventResult::Ignored; - } + None => return EventResult::Ignored(None), }; let line = coords.row + view.offset.row; @@ -884,7 +924,7 @@ impl EditorView { } } - EventResult::Ignored + EventResult::Ignored(None) } MouseEvent { @@ -897,7 +937,7 @@ impl EditorView { let pos = match view.pos_at_screen_coords(doc, row, column) { Some(pos) => pos, - None => return EventResult::Ignored, + None => return EventResult::Ignored(None), }; let mut selection = doc.selection(view.id).clone(); @@ -928,10 +968,10 @@ impl EditorView { match result { Some(view_id) => cxt.editor.tree.focus = view_id, - None => return EventResult::Ignored, + None => return EventResult::Ignored(None), } - let offset = cxt.editor.config.scroll_lines.abs() as usize; + let offset = config.scroll_lines.abs() as usize; commands::scroll(cxt, offset, direction); cxt.editor.tree.focus = current_view; @@ -943,15 +983,15 @@ impl EditorView { kind: MouseEventKind::Up(MouseButton::Left), .. } => { - if !cxt.editor.config.middle_click_paste { - return EventResult::Ignored; + if !config.middle_click_paste { + return EventResult::Ignored(None); } let (view, doc) = current!(cxt.editor); let range = doc.selection(view.id).primary(); if range.to() - range.from() <= 1 { - return EventResult::Ignored; + return EventResult::Ignored(None); } commands::MappableCommand::yank_main_selection_to_primary_clipboard.execute(cxt); @@ -988,7 +1028,7 @@ impl EditorView { return EventResult::Consumed(None); } } - EventResult::Ignored + EventResult::Ignored(None) } MouseEvent { @@ -999,8 +1039,8 @@ impl EditorView { .. } => { let editor = &mut cxt.editor; - if !editor.config.middle_click_paste { - return EventResult::Ignored; + if !config.middle_click_paste { + return EventResult::Ignored(None); } if modifiers == crossterm::event::KeyModifiers::ALT { @@ -1023,10 +1063,10 @@ impl EditorView { return EventResult::Consumed(None); } - EventResult::Ignored + EventResult::Ignored(None) } - _ => EventResult::Ignored, + _ => EventResult::Ignored(None), } } } @@ -1069,9 +1109,6 @@ impl Component for EditorView { } else { match mode { Mode::Insert => { - // record last_insert key - self.last_insert.1.push(key); - // let completion swallow the event if necessary let mut consumed = false; if let Some(completion) = &mut self.completion { @@ -1095,8 +1132,15 @@ impl Component for EditorView { // if completion didn't take the event, we pass it onto commands if !consumed { + if let Some(compl) = cx.editor.last_completion.take() { + self.last_insert.1.push(InsertEvent::CompletionApply(compl)); + } + self.insert_mode(&mut cx, key); + // record last_insert key + self.last_insert.1.push(InsertEvent::Key(key)); + // lastly we recalculate completion if let Some(completion) = &mut self.completion { completion.update(&mut cx); @@ -1117,11 +1161,11 @@ impl Component for EditorView { // if the command consumed the last view, skip the render. // on the next loop cycle the Application will then terminate. if cx.editor.should_close() { - return EventResult::Ignored; + return EventResult::Ignored(None); } - + let config = cx.editor.config(); let (view, doc) = current!(cx.editor); - view.ensure_cursor_in_view(doc, cx.editor.config.scrolloff); + view.ensure_cursor_in_view(doc, config.scrolloff); // Store a history state if not in insert mode. This also takes care of // commiting changes when leaving insert mode. @@ -1138,12 +1182,11 @@ impl Component for EditorView { // how we entered insert mode is important, and we should track that so // we can repeat the side effect. - self.last_insert.0 = - match self.keymaps.get_mut(&mode).unwrap().get(key).kind { - KeymapResultKind::Matched(command) => command, - // FIXME: insert mode can only be entered through single KeyCodes - _ => unimplemented!(), - }; + self.last_insert.0 = match self.keymaps.get(mode, key) { + KeymapResult::Matched(command) => command, + // FIXME: insert mode can only be entered through single KeyCodes + _ => unimplemented!(), + }; self.last_insert.1.clear(); } (Mode::Insert, Mode::Normal) => { @@ -1163,7 +1206,7 @@ impl Component for EditorView { fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { // clear with background color surface.set_style(area, cx.editor.theme.get("ui.background")); - + let config = cx.editor.config(); // if the terminal size suddenly changed, we need to trigger a resize cx.editor.resize(area.clip_bottom(1)); // -1 from bottom for commandline @@ -1172,7 +1215,7 @@ impl Component for EditorView { self.render_view(cx.editor, doc, view, area, surface, is_focused); } - if cx.editor.config.auto_info { + if config.auto_info { if let Some(mut info) = cx.editor.autoinfo.take() { info.render(area, surface, cx); cx.editor.autoinfo = Some(info) @@ -1213,6 +1256,9 @@ impl Component for EditorView { disp.push_str(&s); } } + if let Some(pseudo_pending) = &cx.editor.pseudo_pending { + disp.push_str(pseudo_pending.as_str()) + } let style = cx.editor.theme.get("ui.text"); let macro_width = if cx.editor.macro_recording.is_some() { 3 diff --git a/helix-term/src/ui/info.rs b/helix-term/src/ui/info.rs index 55b0e65de..272244c1c 100644 --- a/helix-term/src/ui/info.rs +++ b/helix-term/src/ui/info.rs @@ -6,12 +6,8 @@ use tui::widgets::{Block, Borders, Paragraph, Widget}; impl Component for Info { fn render(&mut self, viewport: Rect, surface: &mut Surface, cx: &mut Context) { - let get_theme = |style, fallback| { - let theme = &cx.editor.theme; - theme.try_get(style).unwrap_or_else(|| theme.get(fallback)) - }; - let text_style = get_theme("ui.info.text", "ui.text"); - let popup_style = text_style.patch(get_theme("ui.info", "ui.popup")); + let text_style = cx.editor.theme.get("ui.text.info"); + let popup_style = cx.editor.theme.get("ui.popup.info"); // Calculate the area of the terminal to modify. Because we want to // render at the bottom right, we use the viewport's width and height diff --git a/helix-term/src/ui/markdown.rs b/helix-term/src/ui/markdown.rs index 6a7b641ad..5f78c3cc4 100644 --- a/helix-term/src/ui/markdown.rs +++ b/helix-term/src/ui/markdown.rs @@ -6,45 +6,143 @@ use tui::{ use std::sync::Arc; -use pulldown_cmark::{CodeBlockKind, CowStr, Event, Options, Parser, Tag}; +use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag}; use helix_core::{ syntax::{self, HighlightEvent, Syntax}, Rope, }; use helix_view::{ - graphics::{Margin, Rect}, + graphics::{Margin, Rect, Style}, Theme, }; +fn styled_multiline_text<'a>(text: String, style: Style) -> Text<'a> { + let spans: Vec<_> = text + .lines() + .map(|line| Span::styled(line.to_string(), style)) + .map(Spans::from) + .collect(); + Text::from(spans) +} + +pub fn highlighted_code_block<'a>( + text: String, + language: &str, + theme: Option<&Theme>, + config_loader: Arc, + additional_highlight_spans: Option)>>, +) -> Text<'a> { + let mut spans = Vec::new(); + let mut lines = Vec::new(); + + let get_theme = |key: &str| -> Style { theme.map(|t| t.get(key)).unwrap_or_default() }; + let text_style = get_theme(Markdown::TEXT_STYLE); + let code_style = get_theme(Markdown::BLOCK_STYLE); + + let theme = match theme { + Some(t) => t, + None => return styled_multiline_text(text, code_style), + }; + + let rope = Rope::from(text.as_ref()); + let syntax = config_loader + .language_configuration_for_injection_string(language) + .and_then(|config| config.highlight_config(theme.scopes())) + .map(|config| Syntax::new(&rope, config, Arc::clone(&config_loader))); + + let syntax = match syntax { + Some(s) => s, + None => return styled_multiline_text(text, code_style), + }; + + let highlight_iter = syntax + .highlight_iter(rope.slice(..), None, None) + .map(|e| e.unwrap()); + let highlight_iter: Box> = + if let Some(spans) = additional_highlight_spans { + Box::new(helix_core::syntax::merge(highlight_iter, spans)) + } else { + Box::new(highlight_iter) + }; + + let mut highlights = Vec::new(); + for event in highlight_iter { + match event { + HighlightEvent::HighlightStart(span) => { + highlights.push(span); + } + HighlightEvent::HighlightEnd => { + highlights.pop(); + } + HighlightEvent::Source { start, end } => { + let style = highlights + .iter() + .fold(text_style, |acc, span| acc.patch(theme.highlight(span.0))); + + let mut slice = &text[start..end]; + // TODO: do we need to handle all unicode line endings + // here, or is just '\n' okay? + while let Some(end) = slice.find('\n') { + // emit span up to newline + let text = &slice[..end]; + let text = text.replace('\t', " "); // replace tabs + let span = Span::styled(text, style); + spans.push(span); + + // truncate slice to after newline + slice = &slice[end + 1..]; + + // make a new line + let spans = std::mem::take(&mut spans); + lines.push(Spans::from(spans)); + } + + // if there's anything left, emit it too + if !slice.is_empty() { + let span = Span::styled(slice.replace('\t', " "), style); + spans.push(span); + } + } + } + } + + if !spans.is_empty() { + let spans = std::mem::take(&mut spans); + lines.push(Spans::from(spans)); + } + + Text::from(lines) +} + pub struct Markdown { contents: String, config_loader: Arc, - - block_style: String, - heading_style: String, } // TODO: pre-render and self reference via Pin // better yet, just use Tendril + subtendril for references impl Markdown { + const TEXT_STYLE: &'static str = "ui.text"; + const BLOCK_STYLE: &'static str = "markup.raw.inline"; + const HEADING_STYLES: [&'static str; 6] = [ + "markup.heading.1", + "markup.heading.2", + "markup.heading.3", + "markup.heading.4", + "markup.heading.5", + "markup.heading.6", + ]; + pub fn new(contents: String, config_loader: Arc) -> Self { Self { contents, config_loader, - block_style: "markup.raw.inline".into(), - heading_style: "markup.heading".into(), } } - pub fn style_group(mut self, suffix: &str) -> Self { - self.block_style = format!("markup.raw.inline.{}", suffix); - self.heading_style = format!("markup.heading.{}", suffix); - self - } - fn parse(&self, theme: Option<&Theme>) -> tui::text::Text<'_> { // // also 2021-03-04T16:33:58.553 helix_lsp::transport [INFO] <- {"contents":{"kind":"markdown","value":"\n```rust\ncore::num\n```\n\n```rust\npub const fn saturating_sub(self, rhs:Self) ->Self\n```\n\n---\n\n```rust\n```"},"range":{"end":{"character":61,"line":101},"start":{"character":47,"line":101}}} // let text = "\n```rust\ncore::iter::traits::iterator::Iterator\n```\n\n```rust\nfn collect>(self) -> B\nwhere\n Self: Sized,\n```\n\n---\n\nTransforms an iterator into a collection.\n\n`collect()` can take anything iterable, and turn it into a relevant\ncollection. This is one of the more powerful methods in the standard\nlibrary, used in a variety of contexts.\n\nThe most basic pattern in which `collect()` is used is to turn one\ncollection into another. You take a collection, call [`iter`](https://doc.rust-lang.org/nightly/core/iter/traits/iterator/trait.Iterator.html) on it,\ndo a bunch of transformations, and then `collect()` at the end.\n\n`collect()` can also create instances of types that are not typical\ncollections. For example, a [`String`](https://doc.rust-lang.org/nightly/core/iter/std/string/struct.String.html) can be built from [`char`](type@char)s,\nand an iterator of [`Result`](https://doc.rust-lang.org/nightly/core/result/enum.Result.html) items can be collected\ninto `Result, E>`. See the examples below for more.\n\nBecause `collect()` is so general, it can cause problems with type\ninference. As such, `collect()` is one of the few times you'll see\nthe syntax affectionately known as the 'turbofish': `::<>`. This\nhelps the inference algorithm understand specifically which collection\nyou're trying to collect into.\n\n# Examples\n\nBasic usage:\n\n```rust\nlet a = [1, 2, 3];\n\nlet doubled: Vec = a.iter()\n .map(|&x| x * 2)\n .collect();\n\nassert_eq!(vec![2, 4, 6], doubled);\n```\n\nNote that we needed the `: Vec` on the left-hand side. This is because\nwe could collect into, for example, a [`VecDeque`](https://doc.rust-lang.org/nightly/core/iter/std/collections/struct.VecDeque.html) instead:\n\n```rust\nuse std::collections::VecDeque;\n\nlet a = [1, 2, 3];\n\nlet doubled: VecDeque = a.iter().map(|&x| x * 2).collect();\n\nassert_eq!(2, doubled[0]);\nassert_eq!(4, doubled[1]);\nassert_eq!(6, doubled[2]);\n```\n\nUsing the 'turbofish' instead of annotating `doubled`:\n\n```rust\nlet a = [1, 2, 3];\n\nlet doubled = a.iter().map(|x| x * 2).collect::>();\n\nassert_eq!(vec![2, 4, 6], doubled);\n```\n\nBecause `collect()` only cares about what you're collecting into, you can\nstill use a partial type hint, `_`, with the turbofish:\n\n```rust\nlet a = [1, 2, 3];\n\nlet doubled = a.iter().map(|x| x * 2).collect::>();\n\nassert_eq!(vec![2, 4, 6], doubled);\n```\n\nUsing `collect()` to make a [`String`](https://doc.rust-lang.org/nightly/core/iter/std/string/struct.String.html):\n\n```rust\nlet chars = ['g', 'd', 'k', 'k', 'n'];\n\nlet hello: String = chars.iter()\n .map(|&x| x as u8)\n .map(|x| (x + 1) as char)\n .collect();\n\nassert_eq!(\"hello\", hello);\n```\n\nIf you have a list of [`Result`](https://doc.rust-lang.org/nightly/core/result/enum.Result.html)s, you can use `collect()` to\nsee if any of them failed:\n\n```rust\nlet results = [Ok(1), Err(\"nope\"), Ok(3), Err(\"bad\")];\n\nlet result: Result, &str> = results.iter().cloned().collect();\n\n// gives us the first error\nassert_eq!(Err(\"nope\"), result);\n\nlet results = [Ok(1), Ok(3)];\n\nlet result: Result, &str> = results.iter().cloned().collect();\n\n// gives us the list of answers\nassert_eq!(Ok(vec![1, 3]), result);\n```"; @@ -58,36 +156,36 @@ impl Markdown { let mut spans = Vec::new(); let mut lines = Vec::new(); - fn to_span(text: pulldown_cmark::CowStr) -> Span { - use std::ops::Deref; - Span::raw::>(match text { - CowStr::Borrowed(s) => s.into(), - CowStr::Boxed(s) => s.to_string().into(), - CowStr::Inlined(s) => s.deref().to_owned().into(), - }) - } + let get_theme = |key: &str| -> Style { theme.map(|t| t.get(key)).unwrap_or_default() }; + let text_style = get_theme(Self::TEXT_STYLE); + let code_style = get_theme(Self::BLOCK_STYLE); + let heading_styles: Vec