diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2fb35ace66..8be1b83c1c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,4 +1,9 @@ -* @pokey @AndreasArvidsson @phillco +* @cursorless-dev/code-owners -*keyboard* @pokey @AndreasArvidsson @phillco @josharian -*Keyboard* @pokey @AndreasArvidsson @phillco @josharian +*keyboard* @cursorless-dev/code-owners @josharian +*Keyboard* @cursorless-dev/code-owners @josharian + +*neovim* @cursorless-dev/code-owners @saidelike @fidgetingbits +*Neovim* @cursorless-dev/code-owners @saidelike @fidgetingbits +*nvim* @cursorless-dev/code-owners @saidelike @fidgetingbits +*Nvim* @cursorless-dev/code-owners @saidelike @fidgetingbits diff --git a/.github/actions/test-neovim-lua/action.yml b/.github/actions/test-neovim-lua/action.yml new file mode 100644 index 0000000000..d769fbaac2 --- /dev/null +++ b/.github/actions/test-neovim-lua/action.yml @@ -0,0 +1,17 @@ +name: "Neovim Lua Tests" +description: "Set up Neovim Lua environment and run Busted tests" +runs: + using: "composite" + steps: + - uses: leafo/gh-actions-lua@v9 + with: + luaVersion: "luajit-2.1.0-beta3" + - uses: leafo/gh-actions-luarocks@v4 + - shell: bash + run: | + luarocks install busted + luarocks install luafilesystem + - shell: bash + run: | + cd cursorless.nvim + busted --run unit diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index c9ac7ea536..4db5203bfe 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -40,6 +40,41 @@ jobs: registryUrl: https://marketplace.visualstudio.com extensionFile: ${{ steps.publishToOpenVSX.outputs.vsixPath }} + publish-neovim-extension: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' }} + environment: production + env: + CURSORLESS_REPO_ROOT: ${{ github.workspace }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.CURSORLESS_BOT_TOKEN }} + - run: corepack enable + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + - run: pnpm --color install + - run: pnpm --color compile + - run: pnpm --color --filter '!cursorless-org' --filter '!cursorless-org-*' build + env: + CURSORLESS_DEPLOY: true + - name: Configure GPG Key + run: | + echo -n "$GPG_SIGNING_KEY" | base64 --decode | gpg --import + env: + GPG_SIGNING_KEY: ${{ secrets.CURSORLESS_BOT_GPG_SIGNING_KEY }} + - name: git config + run: | + git config user.name cursorless-bot + git config user.email 98099035+cursorless-bot@users.noreply.github.com + git config user.signingkey A9387720AFC62221 + git config commit.gpgsign true + - name: Push compiled files to cursorless.nvim plugin repo + run: bash -x scripts/deploy-cursorless-nvim.sh + push-cursorless-talon: name: Push cursorless-talon subrepo runs-on: ubuntu-latest diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index f82f5783fb..daa024e3ce 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -24,6 +24,8 @@ jobs: node-version-file: .nvmrc cache: pnpm - run: pnpm --color install + - uses: leafo/gh-actions-lua@v9 + - uses: leafo/gh-actions-luarocks@v4 - uses: pre-commit/action@v3.0.1 - uses: pre-commit-ci/lite-action@v1.0.2 if: always() diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b95591ae75..e0e7c54ac2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,16 +17,18 @@ jobs: fail-fast: false matrix: os: [macos-latest, ubuntu-latest, windows-latest] - vscode_version: [stable] + app_version: [stable] include: - os: ubuntu-latest - vscode_version: legacy + app_version: legacy runs-on: ${{ matrix.os }} env: - VSCODE_VERSION: ${{ matrix.vscode_version }} + APP_VERSION: ${{ matrix.app_version }} + NEOVIM_VERSION: ${{ matrix.app_version == 'stable' && 'stable' || 'v0.10.0' }} VSCODE_CRASH_DIR: ${{ github.workspace }}/artifacts/dumps VSCODE_LOGS_DIR: ${{ github.workspace }}/artifacts/logs CURSORLESS_REPO_ROOT: ${{ github.workspace }} + TEMP_DIR: ${{ github.workspace }}/temp steps: - uses: actions/checkout@v4 - run: corepack enable @@ -34,7 +36,7 @@ jobs: with: node-version-file: .nvmrc cache: pnpm - - run: mkdir -p "${{ env.VSCODE_CRASH_DIR }}" "${{ env.VSCODE_LOGS_DIR }}" + - run: mkdir -p "${{ env.VSCODE_CRASH_DIR }}" "${{ env.VSCODE_LOGS_DIR }}" "${{ env.TEMP_DIR }}" shell: bash - run: pnpm --color install - run: pnpm --color compile @@ -43,9 +45,22 @@ jobs: if: runner.os == 'Linux' - run: pnpm --color test if: runner.os != 'Linux' + - run: bash -x scripts/install-neovim-dependencies.sh + - uses: rhysd/action-setup-vim@v1 + id: vim + with: + version: ${{ env.NEOVIM_VERSION }} + neovim: true + - name: Run neovim tests + run: xvfb-run -a pnpm -F @cursorless/test-harness test:neovim + if: runner.os == 'Linux' + env: + NEOVIM_PATH: ${{ steps.vim.outputs.executable }} + - uses: ./.github/actions/test-neovim-lua/ + if: runner.os == 'Linux' && matrix.app_version == 'stable' - name: Create vscode dist that can be installed locally run: pnpm -F @cursorless/cursorless-vscode populate-dist --local-install - if: runner.os == 'Linux' && matrix.vscode_version == 'stable' + if: runner.os == 'Linux' && matrix.app_version == 'stable' - name: Test create vsix id: createVsix uses: HaaLeo/publish-vscode-extension@v1 @@ -54,10 +69,10 @@ jobs: packagePath: packages/cursorless-vscode/dist dryRun: true - run: mv ${{ steps.createVsix.outputs.vsixPath }} cursorless-development.vsix - if: runner.os == 'Linux' && matrix.vscode_version == 'stable' + if: runner.os == 'Linux' && matrix.app_version == 'stable' - name: Upload vsix uses: actions/upload-artifact@v4 - if: runner.os == 'Linux' && matrix.vscode_version == 'stable' + if: runner.os == 'Linux' && matrix.app_version == 'stable' with: name: vsix path: cursorless-development.vsix diff --git a/.gitignore b/.gitignore index b12c598ac0..0013e6e813 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules *.vsix /package-lock.json *.DS_Store +.luacheckcache # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. @@ -43,5 +44,9 @@ next-env.d.ts # test subset config packages/test-harness/testSubsetGrep.properties +# cursorless-neovim +cursorless.nvim/node/cursorless-neovim +cursorless.nvim/node/test-harness + # nix .direnv/ diff --git a/.luacheckrc b/.luacheckrc new file mode 100644 index 0000000000..f3fa0c0198 --- /dev/null +++ b/.luacheckrc @@ -0,0 +1,8 @@ +std = luajit +cache = true +codes = true +ignore = { "432" } + +globals = { + "vim", +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 33c376f87b..429892ecdf 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -81,3 +81,13 @@ repos: - id: ruff args: [--fix, --exit-non-zero-on-fix] - id: ruff-format + - repo: https://github.com/lunarmodules/luacheck + rev: v1.2.0 + hooks: + - id: luacheck + exclude: ^data/playground/lua/.*\.lua$ + - repo: https://github.com/JohnnyMorganz/StyLua + rev: v0.20.0 + hooks: + - id: stylua + exclude: ^data/playground/lua/.*\.lua$ diff --git a/.vscode/extensions.json b/.vscode/extensions.json index aa6154aa91..f304f9751d 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -7,7 +7,9 @@ "dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "jrieken.vscode-tree-sitter-query", - "wenkokke.tree-sitter-talon", - "usernamehw.commands" + "wenkokke.talonfmt-vscode", + "usernamehw.commands", + "sumneko.lua", + "JohnnyMorganz.stylua" ] } diff --git a/.vscode/launch.json b/.vscode/launch.json index 3e77b73a9e..cd375bd7f1 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -110,6 +110,24 @@ ] }, + // Neovim launch configs + { + "name": "Neovim: Run", + "request": "attach", + "continueOnAttach": true, + "skipFiles": ["/**"], + "preLaunchTask": "Neovim: Build extension", + "type": "node" + }, + { + "name": "Neovim: Test", + "request": "attach", + "continueOnAttach": true, + "skipFiles": ["/**"], + "preLaunchTask": "Neovim: Build extension and tests", + "type": "node" + }, + // Talon launch configs { "name": "Talon: Test", diff --git a/.vscode/settings.json b/.vscode/settings.json index 1dd3f31e07..7922d2b067 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -25,5 +25,15 @@ "files.eol": "\n", "typescript.enablePromptUseWorkspaceTsdk": true, "typescript.tsdk": "node_modules/typescript/lib", - "eslint.workingDirectories": [{ "pattern": "packages/*/" }] + "eslint.workingDirectories": [{ "pattern": "packages/*/" }], + "Lua.runtime.version": "Lua 5.1", + "Lua.diagnostics.globals": ["vim", "talon", "it", "describe"], + "Lua.diagnostics.ignoredFiles": "Disable", + "Lua.workspace.ignoreDir": ["data/playground/lua/"], + "[lua]": { + "editor.defaultFormatter": "JohnnyMorganz.stylua" + }, + "files.associations": { + ".busted": "lua" + } } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index a7d82f13f1..cf5e2230dc 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -152,6 +152,152 @@ "group": "test" }, + // Neovim tasks + { + "label": "Neovim: Build extension", + "dependsOn": [ + "Neovim: Launch neovim", + "Neovim: ESBuild", + "Neovim: Populate dist" + ], + "group": "build" + }, + { + "label": "Neovim: Build extension and tests", + "dependsOn": [ + "Neovim: Launch neovim (test)", + "Neovim: ESBuild", + "Neovim: Populate dist", + "TSBuild", + "Build test harness" + ], + "group": "build" + }, + { + "label": "Neovim: ESBuild", + "type": "npm", + "script": "esbuild", + "path": "packages/cursorless-neovim", + "dependsOn": ["Generate grammar"], + "presentation": { + "reveal": "silent" + }, + "group": "build" + }, + { + "label": "Neovim: Populate dist", + "type": "npm", + "script": "populate-dist", + "path": "packages/cursorless-neovim", + "presentation": { + "reveal": "silent" + }, + "options": { + "env": { + "CURSORLESS_REPO_ROOT": "${workspaceFolder}" + } + }, + "group": "build" + }, + { + "label": "Neovim: Launch neovim", + "type": "process", + "command": "packages/cursorless-neovim/scripts/linux-terminal.sh", + "args": [ + "${workspaceFolder}/packages/cursorless-neovim/scripts/debug-neovim.sh ${workspaceFolder} development" + ], + "osx": { + "command": "osascript", + "args": [ + "-e", + "tell app \"Terminal\" to do script \"${workspaceFolder}/packages/cursorless-neovim/scripts/debug-neovim.sh ${workspaceFolder} development\" activate" + ] + }, + "windows": { + "command": "powershell", + "args": [ + "(New-Object -ComObject WScript.Shell).Run(\"\"\"${workspaceFolder}/packages/cursorless-neovim/scripts/debug-neovim.bat\"\"\", 1, $false)" + ] + }, + "group": "build", + "presentation": { + "reveal": "silent" + }, + "options": { + "env": { + "CURSORLESS_REPO_ROOT": "${workspaceFolder}", + "NVIM_NODE_HOST_DEBUG": "1", + "NVIM_NODE_LOG_FILE": "${workspaceFolder}/packages/cursorless-neovim/out/nvim_node.log", + "NVIM_NODE_LOG_LEVEL": "info", + "CURSORLESS_MODE": "development" + } + } + }, + { + "label": "Neovim: Launch neovim (test)", + "type": "process", + "command": "packages/cursorless-neovim/scripts/linux-terminal.sh", + "args": [ + "${workspaceFolder}/packages/cursorless-neovim/scripts/debug-neovim.sh ${workspaceFolder} test" + ], + "osx": { + "command": "osascript", + "args": [ + "-e", + "tell app \"Terminal\" to do script \"${workspaceFolder}/packages/cursorless-neovim/scripts/debug-neovim.sh ${workspaceFolder} test\" activate" + ] + }, + "windows": { + "command": "powershell", + "args": [ + "(New-Object -ComObject WScript.Shell).Run(\"\"\"${workspaceFolder}/packages/cursorless-neovim/scripts/debug-neovim.bat\"\"\", 1, $false)" + ] + }, + "group": "build", + "presentation": { + "reveal": "silent" + }, + "options": { + "env": { + "CURSORLESS_REPO_ROOT": "${workspaceFolder}", + "NVIM_NODE_HOST_DEBUG": "1", + "NVIM_NODE_LOG_FILE": "${workspaceFolder}/packages/cursorless-neovim/out/nvim_node.log", + "NVIM_NODE_LOG_LEVEL": "info", + "CURSORLESS_MODE": "test" + } + } + }, + { + "label": "Neovim: Show logs", + "type": "shell", + "command": "packages/cursorless-neovim/scripts/show-logs.sh", + "problemMatcher": [], + "isBackground": true, + "presentation": { + "echo": false, + "reveal": "always", + "focus": false, + "panel": "dedicated", + "showReuseMessage": false, + "clear": true + }, + "options": { + "env": { + "CURSORLESS_REPO_ROOT": "${workspaceFolder}" + } + } + // NOTE: We don't have a way on Windows atm due to command with argument inside Run() not working + // so we need to show logs outside of vscode (see #2454) + }, + { + "label": "Neovim: Launch neovim (lua test)", + "type": "shell", + "command": "busted --run unit", + "options": { + "cwd": "cursorless.nvim" + } + }, + // cursorless.org { "label": "Serve cursorless.org", diff --git a/cursorless-talon-dev/src/cursorless_dev_vscode.talon b/cursorless-talon-dev/src/cursorless_dev_vscode.talon index e11099901b..e711034aac 100644 --- a/cursorless-talon-dev/src/cursorless_dev_vscode.talon +++ b/cursorless-talon-dev/src/cursorless_dev_vscode.talon @@ -23,3 +23,12 @@ debug edit subset: debug {user.cursorless_launch_configuration}: user.run_rpc_command("commands.startDebugging", cursorless_launch_configuration) user.run_rpc_command("workbench.debug.action.focusRepl") + +neovim log: + user.run_rpc_command("workbench.action.tasks.runTask", "Neovim: Show logs") +debug neovim: + user.run_rpc_command("commands.startDebugging", "Neovim: Run") + user.run_rpc_command("workbench.action.tasks.showTasks") +debug test neovim: + user.run_rpc_command("commands.startDebugging", "Neovim: Test") + user.run_rpc_command("workbench.action.tasks.showTasks") diff --git a/cursorless.nvim/.busted b/cursorless.nvim/.busted new file mode 100644 index 0000000000..adf4c7b64d --- /dev/null +++ b/cursorless.nvim/.busted @@ -0,0 +1,10 @@ +return { + _all = { + lua = './test/nvim-shim.sh', + output = "TAP", + ["defer-print"] = false, + }, + unit = { + ROOT = {'./test/unit/'}, + }, + } diff --git a/cursorless.nvim/CONTRIBUTING.md b/cursorless.nvim/CONTRIBUTING.md new file mode 100644 index 0000000000..2eb25f2708 --- /dev/null +++ b/cursorless.nvim/CONTRIBUTING.md @@ -0,0 +1,7 @@ +# Contributing + +Welcome! So glad you've decided to help make Cursorless in Neovim better. + +Note that Cursorless is maintained as a monorepo, hosted at [`cursorless`](https://github.com/cursorless-dev/cursorless), and the source of truth for all of the files here lives there, so that's where you'll want to file a PR. We automatically deploy from our monorepo to the [cursorless.nvim repo](https://github.com/hands-free-vim/cursorless.nvim) in CI. + +See [the Cursorless neovim contributor docs](https://www.cursorless.org/docs/contributing/cursorless-in-neovim/) to get started. diff --git a/cursorless.nvim/LICENSE b/cursorless.nvim/LICENSE new file mode 100644 index 0000000000..e635b31fe4 --- /dev/null +++ b/cursorless.nvim/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Cedric Halbronn, Brandon Virgil Rule, et al. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/cursorless.nvim/README.md b/cursorless.nvim/README.md new file mode 100644 index 0000000000..24ef417dc8 --- /dev/null +++ b/cursorless.nvim/README.md @@ -0,0 +1,118 @@ + + +- [cursorless.nvim](#cursorlessnvim) + - [Prerequisites](#prerequisites) + - [Installation](#installation) + - [1. Install Cursorless neovim plugin](#1-install-cursorless-neovim-plugin) + - [Option A: Lazy installation](#option-a-lazy-installation) + - [Option B: Manual installation](#option-b-manual-installation) + - [2. Tell neovim to run the plugin](#2-tell-neovim-to-run-the-plugin) + - [3. Activate Cursorless commands in Talon](#3-activate-cursorless-commands-in-talon) + - [Configuration](#configuration) + - [Keyboard shortcut](#keyboard-shortcut) + - [Absolute row numbers](#absolute-row-numbers) + - [Frequently asked questions](#frequently-asked-questions) + - [nvim does not support Lazy?](#nvim-does-not-support-lazy) + - [nvim does not find the `neovim` globally installed package?](#nvim-does-not-find-the-neovim-globally-installed-package) + - [Contributors](#contributors) + + + +# cursorless.nvim + +Very experimental Neovim plugin providing partial Cursorless support. We support much of the core functionality, but many features are not yet implemented, in particular hats ([#2567](https://github.com/cursorless-dev/cursorless/issues/2567)) and language-specific scopes ([#2568](https://github.com/cursorless-dev/cursorless/issues/2568)). Expect some rough edges, but please give it a try, and if you like it, consider [contributing](https://www.cursorless.org/docs/contributing/cursorless-in-neovim/)! + +## Prerequisites + +- [neovim](https://neovim.io/) (>= v0.10.0) +- [Talon voice](https://talonvoice.com/) +- [neovim-talon](https://github.com/hands-free-vim/neovim-talon) +- [node/npm](https://nodejs.org/en) +- [neovim node package](https://github.com/neovim/node-client) (>= 5.1.0 installed globally) +- [talon.nvim](https://github.com/hands-free-vim/talon.nvim) (likely required, unless standalone neovim + GUI (nvim-qt.exe, neovide, etc) + +## Installation + +### 1. Install Cursorless neovim plugin + +Ideally, you want to use a neovim plugin manager like [lazy.nvim](https://github.com/folke/lazy.nvim). + +#### Option A: Lazy installation + +After the typical [lazy setup](https://github.com/folke/lazy.nvim?tab=readme-ov-file#-installation), you'll have to add the `cursorless.nvim` plugin to your `init.lua`. + +```lua +require('lazy').setup({ + 'hands-free-vim/cursorless.nvim', +}) +``` + +#### Option B: Manual installation + +This method is not recommended but you can try directly cloning the plugin into your nvim data folder: + +``` +git clone https://github.com/hands-free-vim/cursorless.nvim +``` + +### 2. Tell neovim to run the plugin + +If you aren't using a plugin manager that automatically calls setup for you (e.g. it is needed for lazy), you will need this somewhere in your neovim config, e.g. in [init.lua](https://neovim.io/doc/user/lua-guide.html#lua-guide-config): + +```lua +require("cursorless").setup() +``` + +### 3. Activate Cursorless commands in Talon + +Add a `.talon` file like the following anywhere in your Talon user directory (e.g. named `cursorless_neovim.talon`): + +```talon +app: neovim +- +tag(): user.cursorless +``` + +## Configuration + +### Keyboard shortcut + +By default the keyboard shortcut used to communicate with cursorless is ``, but this might not work for +everybody and is configurable. You can change it by passing a different value in the configuration options passed to +`setup()`: + +```lua +require("cursorless").setup({ shortcut = ``}) +``` + +_IMPORTANT_: If you change this shortcut, be sure to set the corresponding neovim-talon setting. This can be done by +having a `.talon` file somewhere in your talon user directory that contains the following: + +```talon +settings(): + user.neovim_command_server_shortcut = "ctrl-q" +``` + +### Absolute row numbers + +You MUST currently use absolute row numbers in order to target rows using cursorless. The `talon.nvim` plugin will +configure this automatically, but your own config may be overriding it. Be sure to disable relative numbers. + +## Frequently asked questions + +### nvim does not support Lazy? + +Some Linux package managers ship with a version of `nvim` too old for Lazy. If this is the case, [install nvim](https://github.com/neovim/neovim/blob/master/INSTALL.md) via another method. + +### nvim does not find the `neovim` globally installed package? + +If you are on Linux, avoid using the snap package for `npm` as it may not be able to globally expose the neovim npm package due to sandboxing. If this is the case, install node via another method (nvm, brew, etc). + +## Contributing + +Welcome! So glad you've decided to help make Cursorless in Neovim better. + +Note that Cursorless is maintained as a monorepo, hosted at [`cursorless`](https://github.com/cursorless-dev/cursorless), and the source of truth for all of the files here lives there, so that's where you'll want to file a PR. We automatically deploy from our monorepo to the [cursorless.nvim repo](https://github.com/hands-free-vim/cursorless.nvim) in CI. + +See [the Cursorless neovim contributor docs](https://www.cursorless.org/docs/contributing/cursorless-in-neovim/) to get started. diff --git a/cursorless.nvim/lua/cursorless/config.lua b/cursorless.nvim/lua/cursorless/config.lua new file mode 100644 index 0000000000..06d7b0c9b6 --- /dev/null +++ b/cursorless.nvim/lua/cursorless/config.lua @@ -0,0 +1,27 @@ +local M = {} + +local function default_shortcut() + if require("cursorless.utils").is_platform_macos() then + return "" + end + + if require("cursorless.utils").is_platform_windows() then + return "" + end + + return "" +end + +local config = { + shortcut = default_shortcut(), +} + +function M.set_config(user_config) + return vim.tbl_deep_extend("force", config, user_config or {}) +end + +function M.get_config() + return config +end + +return M diff --git a/cursorless.nvim/lua/cursorless/cursorless.lua b/cursorless.nvim/lua/cursorless/cursorless.lua new file mode 100644 index 0000000000..b01f16de2a --- /dev/null +++ b/cursorless.nvim/lua/cursorless/cursorless.lua @@ -0,0 +1,63 @@ +local M = {} + +-- Get the first and last visible line of the current window/buffer +-- @see https://vi.stackexchange.com/questions/28471/get-first-and-last-visible-line-from-other-buffer-than-current +-- w0/w$ are indexed from 1, similarly to what is shown in neovim +-- e.g. :lua print(vim.inspect(require('cursorless').window_get_visible_lines()))" +-- window_get_visible_lines +-- { [1] = 28, [2] = 74 } +function M.window_get_visible_lines() + -- print('window_get_visible_lines()') + return { vim.fn.line("w0"), vim.fn.line("w$") } +end + +-- Get the coordinates of the current selection +-- To manually test follow these steps: +-- 1. In command mode :vmap lua print(vim.inspect(require('cursorless').buffer_get_selection())) +-- 2. type "hello" on the first line and "world" on the second line +-- 3. Enter visual mode and select "hello" on the first line and continue selection with "world" +-- on the second line. +-- 4. Hit ctrl+a to show the selection: {1, 1, 2, 5, false} +-- 5. Hit 'o' to swap the cursor position and hit ctrl+a again: {1, 1, 2, 5, true} +-- +-- If you want to directly see how it is parsed in the node extension: +-- 1. run in command mode :vmap :call CursorlessLoadExtension() +-- 2. Select some text and hit ctrl+a +function M.buffer_get_selection() + local start_pos = vim.fn.getpos("v") -- start of visual selection + local start_line, start_col = start_pos[2], start_pos[3] + local end_pos = vim.fn.getpos(".") -- end of visual selection (cursor position) + local end_line, end_col = end_pos[2], end_pos[3] + local reverse = false + local mode = vim.api.nvim_get_mode().mode + + -- Invert the values depending on if the cursor is before the start + if end_line < start_line or end_col < start_col then + start_line, start_col, end_line, end_col = + end_line, end_col, start_line, start_col + reverse = true + end + + -- See https://github.com/cursorless-dev/cursorless/issues/2537 if you want to add more modes + if mode == "V" then + -- Line and block-based visual modes are line-based, so we don't need to track the columns + start_col = 1 + end_col = nil + end + + return { start_line, start_col, end_line, end_col, reverse } +end + +-- https://github.com/nvim-treesitter/nvim-treesitter/blob/master/lua/nvim-treesitter/ts_utils.lua#L278 +-- If you have a buffer with the line: "hello world" +-- :lua require("cursorless.cursorless").select_range(1, 2, 1, 4) +-- will highlight "llo" +-- NOTE: works for any mode (n,i,v,nt) except in t mode +function M.select_range(start_line, start_col, end_line, end_col) + vim.cmd([[silent! normal! :noh]]) + vim.api.nvim_win_set_cursor(0, { start_line, start_col }) + vim.cmd([[silent! normal v]]) + vim.api.nvim_win_set_cursor(0, { end_line, end_col }) +end + +return M diff --git a/cursorless.nvim/lua/cursorless/init.lua b/cursorless.nvim/lua/cursorless/init.lua new file mode 100644 index 0000000000..e86114113a --- /dev/null +++ b/cursorless.nvim/lua/cursorless/init.lua @@ -0,0 +1,107 @@ +local M +local function register_functions() + local utils = require("cursorless.utils") + local path = utils.cursorless_nvim_path() + -- revert to using forward slashes as it works when passed to remote#host#RegisterPlugin() + if utils.is_platform_windows() then + path = path:gsub("\\", "/") + end + vim.fn["remote#host#RegisterPlugin"]( + "node", + path .. "/node/command-server/", + { + { + type = "function", + name = "CommandServerLoadExtension", + sync = false, + opts = vim.empty_dict(), + }, + { + type = "function", + name = "CommandServerRunCommand", + sync = false, + opts = vim.empty_dict(), + }, + } + ) + vim.fn["remote#host#RegisterPlugin"]( + "node", + path .. "/node/cursorless-neovim/", + { + { + type = "function", + name = "CursorlessLoadExtension", + sync = false, + opts = vim.empty_dict(), + }, + } + ) + vim.fn["remote#host#RegisterPlugin"]("node", path .. "/node/test-harness/", { + { + type = "function", + name = "TestHarnessRun", + sync = false, + opts = vim.empty_dict(), + }, + }) +end + +-- this triggers loading the node process as well as calling one function +-- in the cursorless-neovim, command-server and neovim-registry extensions +-- in order to initialize them +local function load_extensions() + vim.fn.CursorlessLoadExtension() + + if os.getenv("CURSORLESS_MODE") == "test" then + -- make sure cursorless is loaded before starting the tests + vim.uv.sleep(1000) + vim.fn.TestHarnessRun() + else + vim.fn.CommandServerLoadExtension() + end +end + +-- Cursorless command-server shortcut: CTRL+q +-- https://stackoverflow.com/questions/40504408/can-i-map-a-key-binding-to-a-function-in-vimrc +-- https://stackoverflow.com/questions/7642746/is-there-any-way-to-view-the-currently-mapped-keys-in-vim +-- luacheck:ignore 631 +-- https://stackoverflow.com/questions/3776117/what-is-the-difference-between-the-remap-noremap-nnoremap-and-vnoremap-mapping +local function configure_command_server_shortcut(shortcut) + -- these mappings don't change the current mode + -- https://neovim.io/doc/user/api.html#nvim_set_keymap() + -- https://www.reddit.com/r/neovim/comments/pt92qn/mapping_cd_in_terminal_mode/ + local modes = { "i", "n", "c", "v", "t" } + for _, mode in ipairs(modes) do + vim.api.nvim_set_keymap( + mode, + shortcut, + "lua vim.fn.CommandServerRunCommand()", + { noremap = true } + ) + end +end + +local function setup(user_config) + if vim.fn.has("nvim-0.10.0") == 0 then + vim.api.nvim_err_writeln( + "ERROR: Cursorless requires Neovim 0.10.0 or later" + ) + return + end + local config = require("cursorless.config").set_config(user_config) + register_functions() + load_extensions() + configure_command_server_shortcut(config.shortcut) +end + +local cursorless = require("cursorless.cursorless") +M = { + setup = setup, + config = require("cursorless.config").get_config, + window_get_visible_lines = cursorless.window_get_visible_lines, + buffer_get_selection = cursorless.buffer_get_selection, + buffer_get_selection_text = cursorless.buffer_get_selection_text, + select_range = cursorless.select_range, +} + +return M diff --git a/cursorless.nvim/lua/cursorless/utils.lua b/cursorless.nvim/lua/cursorless/utils.lua new file mode 100644 index 0000000000..0023cfcccd --- /dev/null +++ b/cursorless.nvim/lua/cursorless/utils.lua @@ -0,0 +1,69 @@ +local M = {} + +-- :lua print(require('cursorless.utils').is_platform_windows()) +function M.is_platform_windows() + return vim.uv.os_uname().version:find("Windows") +end + +-- :lua print(require('cursorless.utils').is_platform_macos()) +function M.is_platform_macos() + return vim.uv.os_uname().version:find("Darwin") +end + +-- :lua print(require('cursorless.utils').get_path_separator()) +function M.get_path_separator() + if M.is_platform_windows() then + return "\\" + end + return "/" +end + +-- https://www.reddit.com/r/neovim/comments/tk1hby/get_the_path_to_the_current_lua_script_in_neovim/ +-- https://pgl.yoyo.org/luai/i/debug.getinfo +-- https://www.gammon.com.au/scripts/doc.php?lua=debug.getinfo +-- e.g. :lua print(require('cursorless.utils').cursorless_nvim_path()) +-- outputs: C:\Users\User\AppData\Local\nvim-data\lazy\talon.nvim +-- NOTE: Development cursorless-neovim is installed in: C:\Users\User\AppData\Local\nvim\rplugin\node\cursorless-neovim +function M.cursorless_nvim_path() + --source_file=@C:/Users/User/AppData/Local/nvim-data/lazy/talon.nvim/lua/talon/utils.lua + local str = debug.getinfo(1, "S").source + -- print(('source_file=%s'):format(str)) + -- skip as the file name is prefixed by "@" + str = str:sub(2) + -- print(('source_file2=%s'):format(str)) + if M.is_platform_windows() then + str = str:gsub("/", "\\") + -- print('is_platform_windows') + end + -- print(('source_file3=%s'):format(str)) + -- remove where our current file is located to get talon.nvim base path + str = str:sub(0, -1 - #"lua/cursorless/utils.lua") + -- print(('talon.nvim=%s'):format(str)) + return str +end + +-- assumes we are in terminal mode and switch to normal terminal mode +-- https://www.reddit.com/r/neovim/comments/uk3xmq/change_mode_in_lua/ +-- https://neovim.io/doc/user/api.html#nvim_feedkeys() +-- https://neovim.io/doc/user/builtin.html#feedkeys() +-- https://neovim.io/doc/user/api.html#nvim_replace_termcodes() +-- e.g. run in command mode :tmap lua mode_switch_nt() +function M.mode_switch_nt() + local key = vim.api.nvim_replace_termcodes("", true, false, true) + vim.api.nvim_feedkeys(key, "n", false) +end + +-- assumes we are in normal terminal mode and switch to terminal mode +-- e.g. run in command mode :nmap lua mode_switch_t() +function M.mode_switch_t() + vim.api.nvim_feedkeys("i", "n", true) +end + +-- paste what is in the clipboard +-- https://www.baeldung.com/linux/vim-paste-text +-- e.g. run in command mode :imap lua require('cursorless.utils').paste() +function M.paste() + vim.cmd([[silent! normal! "+p]]) +end + +return M diff --git a/cursorless.nvim/node/command-server/README.md b/cursorless.nvim/node/command-server/README.md new file mode 100644 index 0000000000..4decab2819 --- /dev/null +++ b/cursorless.nvim/node/command-server/README.md @@ -0,0 +1,3 @@ +# Command server + +This directory contains a minified version of a `command-server`, which enables us to communicate with node from Talon. It is based on [command-server we use for VSCode](https://github.com/pokey/command-server/), but modified to run in a Neovim node subprocess. It can be built by following the instructions from https://github.com/hands-free-vim/command-server/tree/neovim#build diff --git a/cursorless.nvim/node/command-server/index/index.cjs b/cursorless.nvim/node/command-server/index/index.cjs new file mode 100644 index 0000000000..a6382b5152 --- /dev/null +++ b/cursorless.nvim/node/command-server/index/index.cjs @@ -0,0 +1,327 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if ((from && typeof from === "object") || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { + get: () => from[key], + enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable, + }); + } + return to; +}; +var __toCommonJS = (mod) => + __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// src/index.ts +var src_exports = {}; +__export(src_exports, { + default: () => entry, +}); +module.exports = __toCommonJS(src_exports); + +// src/nativeIo.ts +var import_fs = require("fs"); +var import_path2 = require("path"); +var import_constants = require("constants"); + +// src/paths.ts +var import_os = require("os"); +var import_path = require("path"); +function getCommunicationDirPath() { + const info = (0, import_os.userInfo)(); + const suffix = info.uid >= 0 ? `-${info.uid}` : ""; + return (0, import_path.join)( + (0, import_os.tmpdir)(), + `neovim-command-server${suffix}`, + ); +} +function getSignalDirPath() { + return (0, import_path.join)(getCommunicationDirPath(), "signals"); +} +function getRequestPath() { + return (0, import_path.join)(getCommunicationDirPath(), "request.json"); +} +function getResponsePath() { + return (0, import_path.join)(getCommunicationDirPath(), "response.json"); +} + +// src/nativeIo.ts +var import_os2 = require("os"); +var import_promises = require("fs/promises"); + +// src/constants.ts +var NEOVIM_COMMAND_TIMEOUT_MS = 3e3; + +// src/nativeIo.ts +var InboundSignal = class { + constructor(path) { + this.path = path; + } + /** + * Gets the current version of the signal. This version string changes every + * time the signal is emitted, and can be used to detect whether signal has + * been emitted between two timepoints. + * @returns The current signal version or null if the signal file could not be + * found + */ + async getVersion() { + try { + return (await (0, import_promises.stat)(this.path)).mtimeMs.toString(); + } catch (err) { + if (err.code !== "ENOENT") { + throw err; + } + return null; + } + } +}; +var NativeIo = class { + constructor() { + this.responseFile = null; + } + async initialize() { + const communicationDirPath = getCommunicationDirPath(); + console.log(`Creating communication dir ${communicationDirPath}`); + (0, import_fs.mkdirSync)(communicationDirPath, { + recursive: true, + mode: 504, + }); + const stats = (0, import_fs.lstatSync)(communicationDirPath); + const info = (0, import_os2.userInfo)(); + if ( + !stats.isDirectory() || + stats.isSymbolicLink() || + stats.mode & import_constants.S_IWOTH || // On Windows, uid < 0, so we don't worry about it for simplicity + (info.uid >= 0 && stats.uid !== info.uid) + ) { + throw new Error( + `Refusing to proceed because of invalid communication dir ${communicationDirPath}`, + ); + } + } + async prepareResponse() { + if (this.responseFile) { + throw new Error("response is already locked"); + } + this.responseFile = await (0, import_promises.open)( + getResponsePath(), + "wx", + ); + } + async closeResponse() { + if (!this.responseFile) { + throw new Error("response is not locked"); + } + await this.responseFile.close(); + this.responseFile = null; + } + /** + * Reads the JSON-encoded request from the request file, unlinking the file + * after reading. + * @returns A promise that resolves to a Response object + */ + async readRequest() { + const requestPath = getRequestPath(); + const stats = await (0, import_promises.stat)(requestPath); + const request = JSON.parse( + await (0, import_promises.readFile)(requestPath, "utf-8"), + ); + if ( + Math.abs(stats.mtimeMs - /* @__PURE__ */ new Date().getTime()) > + NEOVIM_COMMAND_TIMEOUT_MS + ) { + throw new Error( + "Request file is older than timeout; refusing to execute command", + ); + } + return request; + } + /** + * Writes the response to the response file as JSON. + * @param file The file to write to + * @param response The response object to JSON-encode and write to disk + */ + async writeResponse(response) { + if (!this.responseFile) { + throw new Error("response is not locked"); + } + await this.responseFile.write(`${JSON.stringify(response)} +`); + } + getInboundSignal(name) { + const signalDir = getSignalDirPath(); + const path = (0, import_path2.join)(signalDir, name); + return new InboundSignal(path); + } +}; + +// ../cursorless_fork/packages/neovim-registry/src/NeovimRegistry.ts +var import_node_events = require("node:events"); +var NeovimRegistry = class { + constructor() { + this.apis = /* @__PURE__ */ new Map(); + this.commands = /* @__PURE__ */ new Map(); + this.eventEmitter = new import_node_events.EventEmitter(); + } + registerExtensionApi(extensionId, api) { + this.apis.set(extensionId, api); + } + getExtensionApi(extensionId) { + return this.apis.get(extensionId); + } + registerCommand(commandId, callback) { + this.commands.set(commandId, callback); + } + async executeCommand(commandId, ...rest) { + return await this.commands.get(commandId)(...rest); + } + onEvent(eventName, listener) { + return this.eventEmitter.on(eventName, listener); + } + emitEvent(eventName, ...args) { + return this.eventEmitter.emit(eventName, ...args); + } +}; + +// ../cursorless_fork/packages/neovim-registry/src/index.ts +function getNeovimRegistry() { + if (global._neovimRegistry == null) { + global._neovimRegistry = new NeovimRegistry(); + } + return global._neovimRegistry; +} + +// src/commandRunner.ts +var CommandRunner = class { + constructor(io) { + this.io = io; + this.reloadConfiguration = this.reloadConfiguration.bind(this); + this.runCommand = this.runCommand.bind(this); + this.reloadConfiguration(); + } + reloadConfiguration() {} + /** + * Reads a command from the request file and executes it. Writes information + * about command execution to the result of the command to the response file, + * If requested, will wait for command to finish, and can also write command + * output to response file. See also documentation for Request / Response + * types. + */ + async runCommand() { + console.log( + "------------------------------------------------------------------------------", + ); + await this.io.prepareResponse(); + let request; + try { + request = await this.io.readRequest(); + } catch (err) { + await this.io.closeResponse(); + throw err; + } + const { commandId, args, uuid, returnCommandOutput, waitForFinish } = + request; + const warnings = []; + try { + if (!commandId.match(this.allowRegex)) { + throw new Error("Command not in allowList"); + } + if (this.denyRegex != null && commandId.match(this.denyRegex)) { + throw new Error("Command in denyList"); + } + const commandPromise = getNeovimRegistry().executeCommand( + commandId, + ...args, + ); + let commandReturnValue = null; + if (returnCommandOutput) { + commandReturnValue = await commandPromise; + } else if (waitForFinish) { + await commandPromise; + } + await this.io.writeResponse({ + error: null, + uuid, + returnValue: commandReturnValue, + warnings, + }); + } catch (err) { + await this.io.writeResponse({ + error: err.message, + uuid, + warnings, + }); + } + await this.io.closeResponse(); + } +}; + +// src/singletons/commandRunner.singleton.ts +var cmdRunner_; +function injectCommandRunner(cmdRunner) { + cmdRunner_ = cmdRunner; +} +function commandRunner() { + if (cmdRunner_ == null) { + throw Error("Tried to access CommandRunner before it was injected"); + } + return cmdRunner_; +} + +// src/extension.ts +async function activate() { + const io = new NativeIo(); + await io.initialize(); + const commandRunner2 = new CommandRunner(io); + let focusedElementType; + injectCommandRunner(commandRunner2); + return { + /** + * The type of the focused element in vscode at the moment of the command being executed. + */ + getFocusedElementType: () => focusedElementType, + /** + * These signals can be used as a form of IPC to indicate that an event has + * occurred. + */ + signals: { + /** + * This signal is emitted by the voice engine to indicate that a phrase has + * just begun execution. + */ + prePhrase: io.getInboundSignal("prePhrase"), + }, + }; +} + +// src/index.ts +function entry(plugin) { + plugin.setOptions({ dev: false }); + plugin.registerFunction( + "CommandServerLoadExtension", + async () => await loadExtension(plugin), + { sync: false }, + ); + plugin.registerFunction("CommandServerRunCommand", () => runCommand(), { + sync: false, + }); +} +async function loadExtension(plugin) { + console.log("loadExtension(command-server): start"); + await activate(); + console.log("loadExtension(command-server): done"); +} +async function runCommand() { + console.log("runCommand(command-server): start"); + commandRunner().runCommand(); + console.log("runCommand(command-server): done"); +} diff --git a/cursorless.nvim/node/command-server/package.json b/cursorless.nvim/node/command-server/package.json new file mode 100644 index 0000000000..4bafc7d91f --- /dev/null +++ b/cursorless.nvim/node/command-server/package.json @@ -0,0 +1,33 @@ +{ + "name": "command-server", + "description": "Exposes a file-based RPC API for running VSCode commands", + "publisher": "saidelike", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/hands-free-vim/command-server" + }, + "version": "0.9.1", + "main": "./index/index.cjs", + "private": true, + "devDependencies": { + "@types/glob": "^7.1.3", + "@types/mocha": "8.0.4", + "@types/node": "^18.19.22", + "@types/rimraf": "^3.0.0", + "@types/vscode": "^1.53.0", + "@typescript-eslint/eslint-plugin": "^4.9.0", + "@typescript-eslint/parser": "^4.9.0", + "esbuild": "^0.20.2", + "eslint": "^7.15.0", + "glob": "^7.1.6", + "mocha": "8.1.3", + "typescript": "^4.1.2", + "vscode-test": "^1.4.1", + "neovim": "^5.0.1" + }, + "dependencies": { + "minimatch": "^3.0.4", + "rimraf": "^3.0.2" + } +} diff --git a/cursorless.nvim/test/helpers.lua b/cursorless.nvim/test/helpers.lua new file mode 100644 index 0000000000..500191d14b --- /dev/null +++ b/cursorless.nvim/test/helpers.lua @@ -0,0 +1,16 @@ +-- This file gets linked into plugin/helpers.lua of busted nvim config +-- Functions that are exposed to all tests + +function _G.get_selected_text() + local _, ls, cs = unpack(vim.fn.getpos("v")) + local _, le, ce = unpack(vim.fn.getpos(".")) + return vim.api.nvim_buf_get_text(0, ls - 1, cs - 1, le - 1, ce, {}) +end + +function _G.convert_table_entries(tbl, func) + local mapped = {} + for k, v in pairs(tbl) do + mapped[k] = func(v) + end + return mapped +end diff --git a/cursorless.nvim/test/nvim-shim.sh b/cursorless.nvim/test/nvim-shim.sh new file mode 100755 index 0000000000..860a6e9b2b --- /dev/null +++ b/cursorless.nvim/test/nvim-shim.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +if ! [[ "${PWD}" == *"cursorless.nvim" ]]; then + echo "ERROR: This script must be run from inside cursorless.nvim/ directory" + exit 1 +fi + +test_folder=$(mktemp -d "${TMPDIR-/tmp}"/cursorless-busted-test-XXXXX) +export XDG_CONFIG_HOME="${test_folder}/xdg/config/" +export XDG_STATE_HOME="${test_folder}/xdg/local/state/" +export XDG_DATA_HOME="${test_folder}/xdg/local/share/" +dependency_folder="${XDG_DATA_HOME}/nvim/site/pack/testing/start/" +plugin_folder="${XDG_CONFIG_HOME}/nvim/plugin/" + +mkdir -p "${plugin_folder}" "${XDG_STATE_HOME}" "${dependency_folder}" +ln -sf "${PWD}" "${dependency_folder}/cursorless.nvim" + +# Link in standalone helper functions we want all tests to be able to call +ln -sf "${PWD}/test/helpers.lua" "${plugin_folder}/helpers.lua" + +# shellcheck disable=SC2068 +command nvim --cmd 'set loadplugins' -l $@ +exit_code=$? + +rm -rf "${test_folder}" + +exit $exit_code diff --git a/cursorless.nvim/test/unit/cursorless_spec.lua b/cursorless.nvim/test/unit/cursorless_spec.lua new file mode 100644 index 0000000000..1fff3d4495 --- /dev/null +++ b/cursorless.nvim/test/unit/cursorless_spec.lua @@ -0,0 +1,79 @@ +describe("", function() + local cursorless = require("cursorless.cursorless") + + describe("window_get_visible_lines() ->", function() + it("can read one visible line", function() + local pos = vim.api.nvim_win_get_cursor(0)[2] + local line = vim.api.nvim_get_current_line() + local nline = line:sub(0, pos) .. "hello" .. line:sub(pos + 1) + vim.api.nvim_set_current_line(nline) + + local visible = cursorless.window_get_visible_lines() + assert(table.concat(visible) == table.concat({ 1, 1 })) + end) + + it("can read all lines visible on the window", function() + local maxlines = vim.api.nvim_win_get_height(0) + local lines = {} + for _ = 1, (maxlines + 1) do + table.insert(lines, "hello ") + end + vim.api.nvim_buf_set_lines(0, 0, -1, false, lines) + local visible = cursorless.window_get_visible_lines() + assert(table.concat(visible) == table.concat({ 1, maxlines })) + end) + end) + describe("select_range() ->", function() + it("selects the specified range", function() + local lines = "hello world" + vim.api.nvim_buf_set_lines(0, 0, -1, false, vim.split(lines, "\n")) + cursorless.select_range(1, 2, 1, 4) + + assert(table.concat(_G.get_selected_text()) == "llo") + end) + end) + describe("buffer_get_selection() ->", function() + it( + "can get the forward selection in a format expected by cursorless", + function() + local lines = "hello world" + vim.api.nvim_buf_set_lines(0, 0, -1, false, vim.split(lines, "\n")) + cursorless.select_range(1, 2, 1, 4) + assert( + table.concat( + _G.convert_table_entries( + cursorless.buffer_get_selection(), + tostring + ), + ", " + ) + == table.concat( + _G.convert_table_entries({ 1, 3, 1, 5, false }, tostring), + ", " + ) + ) + end + ) + it( + "can get the backward selection in a format expected by cursorless", + function() + local lines = "hello world" + vim.api.nvim_buf_set_lines(0, 0, -1, false, vim.split(lines, "\n")) + cursorless.select_range(1, 4, 1, 2) + assert( + table.concat( + _G.convert_table_entries( + cursorless.buffer_get_selection(), + tostring + ), + ", " + ) + == table.concat( + _G.convert_table_entries({ 1, 3, 1, 5, true }, tostring), + ", " + ) + ) + end + ) + end) +end) diff --git a/data/fixtures/recorded/actions/copySecondToken.yml b/data/fixtures/recorded/actions/copySecondToken.yml new file mode 100644 index 0000000000..aaf6bbbd60 --- /dev/null +++ b/data/fixtures/recorded/actions/copySecondToken.yml @@ -0,0 +1,36 @@ +languageId: plaintext +command: + version: 5 + spokenForm: copy second token + action: {name: copyToClipboard} + targets: + - type: primitive + modifiers: + - type: ordinalScope + scopeType: {type: token} + start: 1 + length: 1 + usePrePhraseSnapshot: false +initialState: + documentContents: | + + const value = "Hello world"; + selections: + - anchor: {line: 1, character: 0} + active: {line: 1, character: 0} + marks: {} +finalState: + documentContents: | + + const value = "Hello world"; + selections: + - anchor: {line: 1, character: 0} + active: {line: 1, character: 0} + clipboard: value + thatMark: + - type: TokenTarget + contentRange: + start: {line: 1, character: 6} + end: {line: 1, character: 11} + isReversed: false + hasExplicitRange: true diff --git a/data/fixtures/recorded/actions/pasteBeforeToken.yml b/data/fixtures/recorded/actions/pasteBeforeToken.yml new file mode 100644 index 0000000000..0687b07a0d --- /dev/null +++ b/data/fixtures/recorded/actions/pasteBeforeToken.yml @@ -0,0 +1,34 @@ +languageId: plaintext +command: + version: 7 + spokenForm: paste before token + action: + name: pasteFromClipboard + destination: + type: primitive + insertionMode: before + target: + type: primitive + modifiers: + - type: containingScope + scopeType: {type: token} + usePrePhraseSnapshot: true +initialState: + documentContents: hello world + selections: + - anchor: {line: 0, character: 8} + active: {line: 0, character: 8} + marks: {} + clipboard: value +finalState: + documentContents: hello value world + selections: + - anchor: {line: 0, character: 14} + active: {line: 0, character: 14} + thatMark: + - type: UntypedTarget + contentRange: + start: {line: 0, character: 6} + end: {line: 0, character: 11} + isReversed: false + hasExplicitRange: true diff --git a/data/playground/lua/lua.lua b/data/playground/lua/lua.lua index 7673cb7220..59d2603500 100644 --- a/data/playground/lua/lua.lua +++ b/data/playground/lua/lua.lua @@ -34,7 +34,7 @@ local max = x > y and x or y print("The maximum value is: " .. max) -- Functions -function add(x, b) +local function add(x, b) return x + y end @@ -65,10 +65,10 @@ local mt = { end, } -setmetatable(a, mt) +setmetatable(person, mt) -- Closures -function makeCounter() +local function makeCounter() local count = 0 return function() count = count + 1 diff --git a/docs/contributing/CONTRIBUTING.md b/docs/contributing/CONTRIBUTING.md index 05cf198bee..e147c95dea 100644 --- a/docs/contributing/CONTRIBUTING.md +++ b/docs/contributing/CONTRIBUTING.md @@ -59,7 +59,7 @@ locally, you need to run the extension in debug mode. To do so you need to do th 1. Open the Cursorless repository in VSCode (with your regular default profile, _**not**_ with the `cursorlessDevelopment` profile) 2. Say `"debug extension"` to run the extension. If you want to run the tests instead, say `"debug test"`. -If you don't have the `cursorless-talon-dev` files in your Talon user directory as described in step 6 above, then you instead need to run the `workbench.action.debug.selectandstart` command in VSCode and then select either "Run Extension" or "Extension Tests". +If you don't have the `cursorless-talon-dev` files in your Talon user directory as described in step 6 above, then you instead need to run the `workbench.action.debug.selectandstart` command in VSCode and then select either "VSCode: Run" or "VSCode: Test". ### Running a subset of tests diff --git a/docs/contributing/architecture/neovim-test-infrastructure.md b/docs/contributing/architecture/neovim-test-infrastructure.md new file mode 100644 index 0000000000..7fe04eaf10 --- /dev/null +++ b/docs/contributing/architecture/neovim-test-infrastructure.md @@ -0,0 +1,329 @@ +# Neovim test infrastructure + +We'll start with a high-level overview of the architecture of the Cursorless tests for neovim, and then we'll dive into the details. + +## Neovim tests + +Here is the call path when running Neovim tests locally. Note that `->` indicates one file calling another file: + +``` +launch.json -> .vscode/tasks.json -> nvim -u init.lua + +init.lua + -> CursorlessLoadExtension() + -> TestHarnessRun() -> run() -> runAllTests() -> Mocha -> packages/cursorless-neovim-e2e/src/suite/recorded.neovim.test.ts +``` + +And here is the call path when running Neovim tests on CI: + +``` +.github/workflows/test.yml -> packages/test-harness/package.json -> my-ts-node src/scripts/runNeovimTestsCI.ts -> packages/test-harness/src/launchNeovimAndRunTests.ts + +launchNeovimAndRunTests.ts + -> copies packages/test-harness/src/config/init.lua to default nvim config folder + -> nvim --headless + -> read Cursorless logs to determine success or failure + +packages/test-harness/src/config/init.lua + -> CursorlessLoadExtension() + -> TestHarnessRun() -> run() -> runAllTests() -> Mocha + packages/cursorless-neovim-e2e/src/suite/recorded.neovim.test.ts +``` + +### Running Neovim tests locally + +This is supported on Windows, Linux and OSX. + +It starts by running the `Neovim: Test` launch config from `.vscode/launch.json`. This dictates VSCode to attach to the `node` process that is spawned by `nvim` (more on this later). Note that it will only attach when the dependencies have been solved, which is indicated by the `"Neovim: Build extension and tests"` task: + +```json + { + "name": "Neovim: Test", + "request": "attach", + "continueOnAttach": true, + "skipFiles": ["/**"], + "preLaunchTask": "Neovim: Build extension and tests", + "type": "node" + }, +``` + +This effectively runs a series of dependency tasks from `.vscode/tasks.json`: + +```json + { + "label": "Neovim: Build extension and tests", + "dependsOn": [ + "Neovim: Launch neovim (test)", + "Neovim: ESBuild", + "Neovim: Populate dist", + "TSBuild", + "Build test harness", + "Neovim: Show logs" + ], + "group": "build" + }, +``` + +Most of the tasks deal with building the Cursorless code except `"Neovim: Launch neovim (test)"` and `"Neovim: Show logs"` which are self explanatory. + +The `Neovim: Launch neovim (test)` task effectively starts `nvim` as a detached process. It is important because it means VSCode won't wait for `nvim` to exit before considering the task as finished. For example, for Windows it executes the `debug-neovim.bat` script : + +```json + { + "label": "Neovim: Launch neovim (test)", + "type": "process", + "windows": { + "command": "powershell", + "args": [ + "(New-Object -ComObject WScript.Shell).Run(\"\"\"${workspaceFolder}/packages/cursorless-neovim/scripts/debug-neovim.bat\"\"\", 1, $false)" + ] + }, + ... + "options": { + "env": { + "CURSORLESS_REPO_ROOT": "${workspaceFolder}", + "NVIM_NODE_HOST_DEBUG": "1", + "NVIM_NODE_LOG_FILE": "${workspaceFolder}/packages/cursorless-neovim/out/nvim_node.log", + "NVIM_NODE_LOG_LEVEL": "info", + "CURSORLESS_MODE": "test" + } + } +``` + +This ends up passing the `init.lua` script as the default config file (`-u`): + +```bat +nvim -u %CURSORLESS_REPO_ROOT%/init.lua +``` + +This `init.lua` adds the local `cursorless.nvim` relative path to the runtime path and initializes Cursorless: + +```lua +local repo_root = os.getenv("CURSORLESS_REPO_ROOT") +if not repo_root then + error("CURSORLESS_REPO_ROOT is not set. Run via debug-neovim.sh script.") +end +vim.opt.runtimepath:append(repo_root .. "/cursorless.nvim") +... +require("cursorless").setup() +``` + +NOTE: this relies on having symlinks inside `cursorless.nvim/node/` to point to the development paths `packages/cursorless-neovim` and `packages/test-harness`. This is required in order to have all the symbols loaded for debugging. + +This ends up calling `setup()` from `cursorless.nvim/lua/cursorless/init.lua`: + +```lua +local function setup(user_config) + ... + register_functions() + load_extensions() +``` + +First, it calls `register_functions()` to expose the node functions `CursorlessLoadExtension()` and `TestHarnessRun()` into the vim namespace. A side effect is that the `nvim` process loads the `node` process: + +```lua +local function register_functions() + ... + vim.fn["remote#host#RegisterPlugin"]( + "node", + path .. "/node/cursorless-neovim/", + { + { + type = "function", + name = "CursorlessLoadExtension", + sync = false, + opts = vim.empty_dict(), + }, + } + ) + vim.fn["remote#host#RegisterPlugin"]("node", path .. "/node/test-harness/", { + { + type = "function", + name = "TestHarnessRun", + sync = false, + opts = vim.empty_dict(), + }, + }) +``` + +Then, it calls `load_extensions()`. This calls the vim functions in order to load the Cursorless neovim plugin (`CursorlessLoadExtension()`) and start the tests (`TestHarnessRun()`) which ends up calling the previously registered node functions. + +```lua +local function load_extensions() + vim.fn.CursorlessLoadExtension() + + if os.getenv("CURSORLESS_MODE") == "test" then + -- make sure cursorless is loaded before starting the tests + vim.uv.sleep(1000) + vim.fn.TestHarnessRun() +``` + +However, because `nvim` was started with `"NVIM_NODE_HOST_DEBUG": "1"`, when `node` is spawned, `node` will hang and wait for a debugger to attach (`--inspect-brk`). Consequently, `nvim` won't finish loading yet (i.e. it won't finish loading `init.lua`). + +This is handy because it allows VSCode to finish all the tasks required for building the Cursorless neovim plugin (`cursorless-neovim`) and the Tests neovim plugin (`test-harness`), which will finally trigger VSCode to attach to the `node` process. + +When VSCode attaches to the `node` process, `CursorlessLoadExtension()` is called to load the Cursorles neovim plugin and `TestHarnessRun()` is called to start the tests. + +This ends up calling `TestHarnessRun()` from `packages/test-harness/src/index.ts` which calls `run()`: + +```ts +export default function entry(plugin: NvimPlugin) { + plugin.registerFunction("TestHarnessRun", () => run(plugin), { + sync: false, + }); +} + +export async function run(plugin: NvimPlugin): Promise { + ... + await runAllTests(TestType.neovim, TestType.unit); + console.log(`==== TESTS FINISHED: code: ${code}`); +``` + +This ends up calling `runAllTests()` which calls `runTestsInDir()` from `packages/test-harness/src/runAllTests.ts`. + +This ends up using the [Mocha API](https://mochajs.org/) to execute tests which names end with `neovim.test.cjs` (Cursorless tests for neovim) and `test.cjs` (Cursorless unit tests): + +```ts +async function runTestsInDir( + testRoot: string, + filterFiles: (files: string[]) => string[], +): Promise { + // Create the mocha test + const mocha = new Mocha({ + ... + }); + ... + try { + // Run the mocha test + await new Promise((c, e) => { + mocha.run((failures) => { + ... +``` + +Consequently, the recorded tests from `data/fixtures/recorded/` are executed when `packages/cursorless-neovim-e2e/src/suite/recorded.neovim.test.ts` is invoked. + +### Running Neovim tests on CI + +This is supported on Linux only. + +It starts from `.github/workflows/test.yml` which currently only tests the latest stable neovim version on Linux: + +```yml +run: xvfb-run -a pnpm -F @cursorless/test-harness test:neovim +if: runner.os == 'Linux' && matrix.app_version == 'stable' +``` + +This triggers the script in `packages/test-harness/package.json`: + +```json +"test:neovim": "env CURSORLESS_MODE=test my-ts-node src/scripts/runNeovimTestsCI.ts", +``` + +This ends up calling the default function from `package/test-harness/src/scripts/runNeovimTestsCI.ts` which calls `launchNeovimAndRunTests()` from `packages/test-harness/src/launchNeovimAndRunTests.ts`: + +```ts +(async () => { + // Note that we run all extension tests, including unit tests, in neovim, even though + // unit tests could be run separately. + await launchNeovimAndRunTests(); +})(); +``` + +This ends up copying the `packages/test-harness/src/config/init.lua` file into the default nvim config folder `(A)`, starting neovim without a GUI (`--headless`) `(B)` and reading Cursorless logs in order to determine success or failure `(C)`: + +```ts +export async function launchNeovimAndRunTests() { + ... + copyFile(initLuaFile, `${nvimFolder}/init.lua`, (err: any) => { // (A) + if (err) { + console.error(err); + } + }); + ... + const subprocess = cp.spawn(cli, [`--headless`], { // (B) + env: { + ...process.env, + ["NVIM_NODE_LOG_FILE"]: logName, + ["NVIM_NODE_LOG_LEVEL"]: "info", // default for testing + ["CURSORLESS_MODE"]: "test", + }, + }); + ... + tailTest = new Tail(logName, { // (C) + fromBeginning: true, + }); + ... + tailTest.on("line", function (data: string) { + console.log(`neovim test: ${data}`); + if (data.includes("==== TESTS FINISHED:")) { + done = true; + console.log(`done: ${done}`); +``` + +At this stage, we are in a similar situation to the "Cursorless tests for neovim locally" case where `nvim` is started with the `packages/test-harness/src/config/init.lua` config file. Similarly, this `init.lua` adds the local `cursorless.nvim` relative path to the runtime path and initializes Cursorless: + +```lua +local repo_root = os.getenv("CURSORLESS_REPO_ROOT") +... +vim.opt.runtimepath:append(repo_root .. "/dist/cursorless.nvim") +... +require("cursorless").setup() +``` + +This ends up calling `setup()` from `dist/cursorless.nvim/lua/cursorless/init.lua`, which ends up triggering `TestHarnessRun()` and finally the recorded tests from `recorded.neovim.test.ts` using the Mocha API. + +NOTE: Because `NVIM_NODE_HOST_DEBUG` is not set on CI, `nvim` loads entirely right away and tests are executed. + +NOTE: CI uses `dist/cursorless.nvim/` (and not `cursorless.nvim/`), since the symlinks in `cursorless.nvim/` are only created locally in order to get symbols loaded, which we don't need on CI. + +## Lua unit tests + +This is supported on Linux only, both locally and on CI. + +Here is the call path when running lua unit tests locally. Note that `->` indicates one file calling another file: + +``` +launch.json -> .vscode/tasks.json -> cd cursorless.nvim && busted --run unit +cursorless.nvim/.busted + -> lua interpreter: cursorless.nvim/test/nvim-shim.sh -> nvim -l + -> test specification files: cursorless.nvim/test/unit/*_spec.lua +``` + +And here is the call path when running lua unit tests on CI: + +``` +.github/workflows/test.yml -> .github/actions/test-neovim-lua/action.yml -> cd cursorless.nvim && busted --run unit +cursorless.nvim/.busted + -> lua interpreter: cursorless.nvim/test/nvim-shim.sh -> nvim -l + -> test specification files: cursorless.nvim/test/unit/*_spec.lua +``` + +### Running lua unit tests + +Many of the cursorless.nvim lua functions are run in order to complete Cursorless actions and so are already +indirectly tested by the tests described in the [previous section](#3-cursorless-tests-for-neovim). Nevertheless, we run +more specific unit tests in order to give better visibility into exactly which functions are failing. +The [busted](https://github.com/lunarmodules/busted) framework is used to test lua functions defined in cursorless.nvim. +This relies on a `cursorless.nvim/.busted` file which directs busted to use a lua interpreter and test specifications files: + +```bash +return { + _all = { + lua = './test/nvim-shim.sh' + }, + unit = { + ROOT = {'./test/unit/'}, + }, +} +``` + +The `.busted` file declares the `cursorless.nvim/test/nvim-shim.sh` shell wrapper as its lua interpreter. This script sets up an enclosed neovim environment by using [XDG Base +Directory](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html) environment variables (Linux +only) pointing to a temp directory. This allows loading cursorless.nvim, any helpers and (optional) plugins needed to run +the tests. Consequently, the cursorless.nvim lua functions are exposed to the tests. Afterwards, the shim will use `nvim -l ` for each of the [lua test specifications scripts](https://neovim.io/doc/user/starting.html#-l). +The `.busted` file declares that test specifications files are in +`cursorless.nvim/test/unit/`. Any file in that folder ending with `_spec.lua` contains tests and will be executed +by neovim's lua interpreter. +NOTE: Different tests rely on +the same custom test helper functions. These functions are exposed as globals in a file called `helpers.lua` placed in `nvim/plugin/` inside the isolated XDG environment. These helpers themselves also have their own unit tests that will be run by busted. +This busted setup was inspired by this [blog +post](https://hiphish.github.io/blog/2024/01/29/testing-neovim-plugins-with-busted/), which goes into greater detail. diff --git a/docs/contributing/cursorless-in-neovim.md b/docs/contributing/cursorless-in-neovim.md new file mode 100644 index 0000000000..767a278d15 --- /dev/null +++ b/docs/contributing/cursorless-in-neovim.md @@ -0,0 +1,72 @@ +# Cursorless in Neovim + +This document describes how to get set up to work on the Cursorless neovim plugin. You may also find the [Neovim API docs](https://neovim.io/doc/user/api.html) helpful to learn about Neovim extension development. + +Note that Cursorless is maintained as a monorepo, hosted at [`cursorless`](https://github.com/cursorless-dev/cursorless), and the source of truth for all of the files in cursorless.nvim lives there. We automatically deploy from our monorepo to the [cursorless.nvim repo](https://github.com/hands-free-vim/cursorless.nvim) in CI. + +## Initial setup + +### 1. Follow the initial contributor setup guide + +Follow the steps in [CONTRIBUTING.md](./CONTRIBUTING.md#initial-setup). + +### 2. Get production Cursorless neovim working + +Follow the installation steps in [cursorless.nvim](https://github.com/hands-free-vim/cursorless.nvim/tree/main#prerequisites). + +Confirm that production cursorless.nvim is working in neovim, eg say `"take first paint"` in a non-empty document. + +### 3. Add nvim executable path to your PATH + +On Mac and Linux, this should be done automatically. + +On Windows, open the Control Panel, navigate to `User Accounts > User Accounts`. Click on `Change my environment variables`. In the `User variables`, e.g. add the entry `C:\Program Files\Neovim\bin` to your `Path`. + +### 4. (Windows only) Create symlinks for the built plugins + +This step is only required on Windows if you don't run VSCode with Administrator privileges. + +Open a `cmd.exe` with Administrator privileges and create the symbolic links between the source folders and the `cursorless.nvim` destination folder: + +```bat +mklink /D C:\path\to\cursorless\cursorless.nvim\node\cursorless-neovim C:\path\to\cursorless\packages\cursorless-neovim +mklink /D C:\path\to\cursorless\cursorless.nvim\node\test-harness C:\path\to\cursorless\packages\test-harness +``` + +Note that the `C:\path\to\cursorless` path above should match your cloned cursorless repository. + +## Running / testing extension locally + +In order to test out your local version of the extension or to run unit tests locally, you need to run the extension in debug mode. To do so you need to do the following: + +1. Open the Cursorless repository in VSCode (with your regular default profile, _**not**_ with the `cursorlessDevelopment` profile) +2. Say `"neovim log"` to open the neovim log. +3. Say `"debug neovim"` to run the extension. If you want to run the tests instead, say `"debug test neovim"`. + +NOTE: This will spawn a standalone nvim instance that is independent of VSCode. Consequently after you're done debugging, you need to close nvim. + +If you don't have the `cursorless-talon-dev` files in your Talon user directory as described in step 6 of [CONTRIBUTING.md](./CONTRIBUTING.md#initial-setup), then you instead need to run the `workbench.action.debug.selectandstart` command in VSCode and then select either "Neovim: Run" or "Neovim: Test". + +### Running lua tests + +Their are separate cursorless and lua tests. You can run the lua tests by entering the `cursorless.nvim` folder and +running: `busted --run unit`. These tests currently only work on Linux. + +## Sending pull requests + +The source of truth for `cursorless.nvim` lives in the [Cursorless monorepo](https://github.com/cursorless-dev/cursorless/). We automatically push to the [cursorless.nvim](https://github.com/hands-free-vim/cursorless.nvim) repo in CI. If you'd like to contribute to `cursorless.nvim`, please open a PR in the [Cursorless monorepo](https://github.com/cursorless-dev/cursorless/). + +## Frequently asked questions + +### init.lua: module 'cursorless' not found + +The first time you build Cursorless for neovim for debugging, you might encounter this error in `nvim` when it starts: + +``` +Error detected while processing C:\Users\User\AppData\Local\nvim\init.lua: +E5113: Error while calling lua chunk: C:\Users\User\AppData\Local\nvim\init.lua:50: module 'cursorless' not found: +``` + +This is expected because `nvim` is started before Cursorless is built and the `dist/cursorless.nvim` folder does not exist yet. Consequently, close `nvim` and restart your debugging session for it to work. + +If it still does not work, check that your `vim.opt.runtimepath` path point to the right folder as described in the installation instructions above. diff --git a/docs/contributing/tests.md b/docs/contributing/tests.md index 0d54c91070..571445a763 100644 --- a/docs/contributing/tests.md +++ b/docs/contributing/tests.md @@ -13,5 +13,6 @@ We run the above tests in various contexts, both locally and in CI. The contexts - **VSCode**: Today, many of our tests must run within a VSCode context. For some of our tests, this is desirable, because they are designed to test that our code works in VSCode. However, many of our tests (such as scope tests and recorded tests) are not really VSCode-specific, but we haven't yet built the machinery to run them in a more isolated context, which would be much faster. - **Unit tests**: Many of our tests can run in a neutral context, without requiring an actual IDE with editors, etc. Most of these are unit tests in the traditional sense of the word, testing the logic of a small unit of code, such as a function. - **Talon**: For each of our recorded tests, we test that saying the spoken form of the command in Talon results in the command payload that we expect. Note that these tests can only be run locally today. +- **Neovim**: We run a subset of our recorded tests within Neovim to ensure that the given subset of Cursorless works within Neovim. We also have a few lua unit tests that must be run in Neovim. These test the lua functions that Cursorless needs in order to interact with Neovim. To learn more about our Neovim test infrastructure, see [Neovim test infrastructure](./architecture/neovim-test-infrastructure.md). You can get an overview of the various test contexts that exist locally by looking at our VSCode launch configs, which include not only our VSCode tests, but all of our tests. diff --git a/flake.lock b/flake.lock index a1271a0ba5..4e43b25df4 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1709675310, - "narHash": "sha256-w61tqFEmuJ+/1rAwU7nkYZ+dN6sLwyobfLwX2Yn42FE=", + "lastModified": 1718870667, + "narHash": "sha256-jab3Kpc8O1z3qxwVsCMHL4+18n5Wy/HHKyu1fcsF7gs=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "43d259f8d726113fac056e8bb17d5ac2dea3e0a8", + "rev": "9b10b8f00cb5494795e5f51b39210fed4d2b0748", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index b34713f03e..0b53089e89 100644 --- a/flake.nix +++ b/flake.nix @@ -1,7 +1,9 @@ { description = "A Nix-flake-based development environment for Cursorless"; - inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + }; outputs = { self, nixpkgs }: @@ -13,7 +15,43 @@ "aarch64-darwin" ]; forEachSupportedSystem = - f: nixpkgs.lib.genAttrs supportedSystems (system: f { pkgs = import nixpkgs { inherit system; }; }); + f: + nixpkgs.lib.genAttrs supportedSystems ( + system: + f { + pkgs = import nixpkgs { + inherit system; + overlays = [ + # https://github.com/NixOS/nixpkgs/pull/317333 + (final: prev: { + nodePackages = prev.nodePackages // { + neovim = prev.buildNpmPackage rec { + pname = "neovim-node-client"; + version = "5.1.1-dev.0"; + src = prev.fetchFromGitHub { + owner = "neovim"; + repo = "node-client"; + rev = "d99ececf115ddc8ade98467417c1bf0120b676b5"; + hash = "sha256-eiKyhJNz7kH2iX55lkn7NZYTj6yaSZLMZxqiqPxDIPs="; + }; + npmDeps = prev.fetchNpmDeps { + inherit src; + hash = "sha256-UoMq+7evskxtZygycxLBgeUtwrET8jYKeZwMiXdBMAw="; + }; + postInstall = '' + mkdir -p $out/bin + ln -s $out/lib/node_modules/neovim/node_modules/.bin/neovim-node-host $out/bin + ''; + }; + }; + neovim = prev.neovim.override { withNodeJs = true; }; + + }) + + ]; + }; + } + ); pythonVersion = builtins.replaceStrings [ "py" ] [ "python" ] (nixpkgs.lib.importTOML ./pyproject.toml).tool.ruff.target-version; @@ -37,8 +75,13 @@ --set PYTHONPATH $PYTHONPATH ''; })) - python + + pkgs.neovim + pkgs.luajitPackages.busted # for lua testing + pkgs.luarocks # pre-commit doesn't auto-install luarocks + pkgs.ps + pkgs.nodejs ]; # To prevent weird broken non-interactive bash terminal buildInputs = [ pkgs.bashInteractive ]; @@ -46,8 +89,9 @@ if [ ! -f .git/hooks/pre-commit ]; then echo "You can run 'pre-commit install' to install git commit hooks if you want them." fi - pnpm install + + PATH=${pkgs.lib.getBin pkgs.neovim}/bin:$PATH} ''; }; } diff --git a/init.lua b/init.lua new file mode 100644 index 0000000000..8ef08d16cb --- /dev/null +++ b/init.lua @@ -0,0 +1,33 @@ +-- This config file is used for local development and testing. +local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim" +if not (vim.uv or vim.loop).fs_stat(lazypath) then + vim.fn.system({ + "git", + "clone", + "--filter=blob:none", + "https://github.com/folke/lazy.nvim.git", + "--branch=stable", -- latest stable release + lazypath, + }) +end +vim.opt.runtimepath:prepend(lazypath) +require("lazy").setup({ + -- Allows title detection by neovim-talon while testing + "hands-free-vim/talon.nvim", + -- Provides concise mode display while testing. This is useful because talon.nvim sets cmdheight = 0 + "nvim-lualine/lualine.nvim", + dependencies = { "nvim-tree/nvim-web-devicons" }, +}) + +-- Allows better range selection debugging - it allows us to see single character ranges. +vim.o.guicursor = "a:hor20-blink100" + +local repo_root = os.getenv("CURSORLESS_REPO_ROOT") +if not repo_root then + error("CURSORLESS_REPO_ROOT is not set. Run via debug-neovim.sh script.") +end +vim.opt.runtimepath:append(repo_root .. "/cursorless.nvim") + +require("talon").setup() +require("cursorless").setup() +require("lualine").setup() diff --git a/packages/common/src/types/RangeExpansionBehavior.ts b/packages/common/src/types/RangeExpansionBehavior.ts index 5a82df4fa0..2912ff9683 100644 --- a/packages/common/src/types/RangeExpansionBehavior.ts +++ b/packages/common/src/types/RangeExpansionBehavior.ts @@ -7,7 +7,7 @@ export enum RangeExpansionBehavior { */ openOpen = 0, /** - * The decoration's range will not widen when edits occur at the start of end. + * The decoration's range will not widen when edits occur at the start or end. */ closedClosed = 1, /** diff --git a/packages/cursorless-engine/src/CommandHistory.ts b/packages/cursorless-engine/src/CommandHistory.ts index f7a964c3ab..811c421683 100644 --- a/packages/cursorless-engine/src/CommandHistory.ts +++ b/packages/cursorless-engine/src/CommandHistory.ts @@ -7,12 +7,10 @@ import { ReadOnlyHatMap, type CommandHistoryStorage, } from "@cursorless/common"; -import type { - CommandRunner, - CommandRunnerDecorator, -} from "@cursorless/cursorless-engine"; import { produce } from "immer"; import { v4 as uuid } from "uuid"; +import { CommandRunner } from "./CommandRunner"; +import { CommandRunnerDecorator } from "./api/CursorlessEngineApi"; const filePrefix = "cursorlessCommandHistory"; diff --git a/packages/cursorless-neovim-e2e/package.json b/packages/cursorless-neovim-e2e/package.json new file mode 100644 index 0000000000..cd6e2cb553 --- /dev/null +++ b/packages/cursorless-neovim-e2e/package.json @@ -0,0 +1,37 @@ +{ + "name": "@cursorless/cursorless-neovim-e2e", + "version": "1.0.0", + "description": "Our neovim end-to-end tests", + "private": true, + "main": "./out/index.js", + "exports": { + ".": { + "cursorless:bundler": "./src/index.ts", + "default": "./out/index.js" + } + }, + "scripts": { + "compile": "tsc --build", + "watch": "tsc --build --watch", + "clean": "rm -rf ./out tsconfig.tsbuildinfo ./dist ./build" + }, + "keywords": [], + "author": "", + "license": "MIT", + "devDependencies": { + "@types/chai": "^4.3.14", + "@types/js-yaml": "^4.0.9", + "@types/lodash": "4.17.0", + "@types/sinon": "^17.0.3", + "neovim": "5.1.0" + }, + "dependencies": { + "@cursorless/common": "workspace:*", + "@cursorless/neovim-common": "workspace:*", + "@cursorless/neovim-registry": "workspace:*", + "@cursorless/node-common": "workspace:*", + "@cursorless/test-case-recorder": "workspace:*" + }, + "types": "./out/index.d.ts", + "type": "module" +} diff --git a/packages/cursorless-neovim-e2e/src/endToEndTestSetup.ts b/packages/cursorless-neovim-e2e/src/endToEndTestSetup.ts new file mode 100644 index 0000000000..5dbd3b8d8b --- /dev/null +++ b/packages/cursorless-neovim-e2e/src/endToEndTestSetup.ts @@ -0,0 +1,71 @@ +import { IDE, shouldUpdateFixtures, sleep, SpyIDE } from "@cursorless/common"; +import { getCursorlessApi, NeovimIDE } from "@cursorless/neovim-common"; +import { Context } from "mocha"; +import * as sinon from "sinon"; + +/** + * The number of times the current test has been retried. Will be 0 the first + * time the test runs and increase by 1 each time the test fails and needs to be + * rerun. + */ +let retryCount = -1; + +/** + * The title of the previously run test. Used to keep track of + * {@link retryCount}. + */ +let previousTestTitle = ""; + +export function endToEndTestSetup(suite: Mocha.Suite) { + // Disable multiple attempts for now to avoid it to hang for 10 minutes on CI + // suite.timeout("100s"); + // suite.retries(5); + suite.timeout("10s"); + suite.retries(0); + + let ide: IDE; + let injectIde: (ide: IDE) => void; + let spy: SpyIDE | undefined; + let neovimIDE: NeovimIDE; + + setup(async function (this: Context) { + const title = this.test!.fullTitle(); + retryCount = title === previousTestTitle ? retryCount + 1 : 0; + previousTestTitle = title; + ({ ide, injectIde, neovimIDE } = (await getCursorlessApi()).testHelpers!); + spy = new SpyIDE(ide); + injectIde(spy); + }); + + teardown(() => { + sinon.restore(); + injectIde(ide); + }); + + return { + getSpy() { + return spy; + }, + getNeovimIDE() { + return neovimIDE; + }, + }; +} + +/** + * Sleep function for use in tests that will be retried. Doubles the amount of + * time it sleeps each time a test is run, starting from {@link ms} / 4. + * + * If the developer used the update fixtures launch config, we sleep for {@link ms} * + * 2 every time so that they don't get spurious updates to fixtures due to not + * sleeping enough. + * @param ms The baseline number of milliseconds to sleep. + * @returns A promise that will resolve when the sleep is over + */ +export function sleepWithBackoff(ms: number) { + const timeToSleep = shouldUpdateFixtures() + ? ms * 2 + : ms * Math.pow(2, retryCount - 2); + + return sleep(timeToSleep); +} diff --git a/packages/cursorless-neovim-e2e/src/shouldRunTest.ts b/packages/cursorless-neovim-e2e/src/shouldRunTest.ts new file mode 100644 index 0000000000..00f22a988a --- /dev/null +++ b/packages/cursorless-neovim-e2e/src/shouldRunTest.ts @@ -0,0 +1,114 @@ +import { TestCaseFixtureLegacy } from "@cursorless/common"; + +const failingFixtures = [ + // actual finalState.selections.anchor is -1 compared to expected (other fixture.command.action.name == "insertCopyBefore" tests pass fine) + "recorded/actions/cloneToken4", + "recorded/actions/cloneUpToken4", + // -> Error: nvim_execute_lua: Cursor position outside buffer + "recorded/compoundTargets/chuckStartOfBlockPastStartOfFile", + // actual finalState.selections.anchor is -1 compared to expected + "recorded/implicitExpansion/chuckCoreThat", + "recorded/implicitExpansion/chuckLeadingThat", + "recorded/marks/chuckNothing", + // -> wrong fixture.finalState.selections + "recorded/implicitExpansion/cloneThat2", + "recorded/implicitExpansion/cloneThis", + "recorded/implicitExpansion/cloneThis2", +]; + +function isFailingFixture(name: string, fixture: TestCaseFixtureLegacy) { + const action = + typeof fixture.command.action === "object" + ? fixture.command.action.name + : fixture.command.action; + + switch (action) { + // "recorded/actions/insertEmptyLines/puffThis*" -> wrong fixture.finalState.selections and fixture.thatMark.contentRange + case "insertEmptyLinesAround": + return true; + // "recorded/actions/insertEmptyLines/floatThis*" -> Error: nvim_buf_get_lines: Index out of bounds + // -> or actual finalState.selections.anchor is -1 compared to expected + // actual finalState.thatMark.contentRange.start is -1 compared to expected + case "insertEmptyLineAfter": + return true; + // "recorded/actions/insertEmptyLines/dropThis*" -> wrong fixture.finalState.selections and fixture.thatMark.contentRange + case "insertEmptyLineBefore": + return true; + // "recorded/actions/cloneToken*" and "recorded/itemTextual/cloneTwoItems" -> wrong fixture.finalState.selections and fixture.thatMark.contentRange + case "insertCopyAfter": + return true; + // "recorded/implicitExpansion/pour*" -> not supported for now + case "editNewLineAfter": + return true; + // "recorded/actions/{decrement,increment}File" -> are not supported atm + case "decrement": + return true; + case "increment": + return true; + // "recorded/actions/snippets/*" -> not supported for now + case "insertSnippet": + return true; + case "wrapWithSnippet": + return true; + // "recorded/actions/insertEmptyLines/floatThis*" -> wrong fixture.finalState.selections and fixture.thatMark.contentRange + case "breakLine": + return true; + case "joinLines": + return true; + // "recorded/actions/shuffleThis" is not supported atm + case "randomizeTargets": + return true; + // "recorded/actions/pasteBeforeToken" -> wrong fixture.finalState.documentContents/selections/thatMark + case "pasteFromClipboard": + return true; + // "recorded/actions/copySecondToken" -> wrong fixture.finalState.clipboard + case "copyToClipboard": + return true; + } + + // "recorded/lineEndings/*" -> fixture.finalState.documentContents contains \n instead of \r\n + if (name.includes("/lineEndings/")) { + return true; + } + + // "recorded/fallback/take*" -> wrong fixture.finalState.selections + if (name.includes("/fallback/take")) { + return true; + } + + // We blacklist remaining unsorted failing tests + if (failingFixtures.includes(name)) { + return true; + } + + return false; +} + +export function shouldRunTest( + name: string, + fixture: TestCaseFixtureLegacy, +): boolean { + // We don't support decorated symbol marks (hats) yet + const hasMarks = + fixture.initialState.marks != null && + Object.keys(fixture.initialState.marks).length > 0; + + // we don't support multiple selections in neovim (we don't support multiple cursors atm) + const hasMultipleSelections = + fixture.initialState.selections.length > 1 || + (fixture.finalState && fixture.finalState.selections.length > 1); + + // We don't support Tree sitter yet (which requires a code languageId) + const needTreeSitter = fixture.languageId !== "plaintext"; + + if (hasMarks || hasMultipleSelections || needTreeSitter) { + return false; + } + + // Fixtures that will need to be fixed in the future + if (isFailingFixture(name, fixture)) { + return false; + } + + return true; +} diff --git a/packages/cursorless-neovim-e2e/src/suite/recorded.neovim.test.ts b/packages/cursorless-neovim-e2e/src/suite/recorded.neovim.test.ts new file mode 100644 index 0000000000..67e91a6e4a --- /dev/null +++ b/packages/cursorless-neovim-e2e/src/suite/recorded.neovim.test.ts @@ -0,0 +1,94 @@ +import { TestCaseFixtureLegacy, asyncSafety } from "@cursorless/common"; +import { getRecordedTestPaths, runRecordedTest } from "@cursorless/node-common"; +import { + NeovimIDE, + NeovimTextEditorImpl, + NewEditorOptions, + getCursorlessApi, + runCursorlessCommand, +} from "@cursorless/neovim-common"; +import * as yaml from "js-yaml"; +import type { NeovimClient } from "neovim"; +import { promises as fsp } from "node:fs"; +import { endToEndTestSetup, sleepWithBackoff } from "../endToEndTestSetup"; +import { shouldRunTest } from "../shouldRunTest"; + +suite("recorded test cases", async function () { + const { getSpy, getNeovimIDE } = endToEndTestSetup(this); + + suiteSetup(async () => {}); + + const tests = getRecordedTestPaths(); + + for (const { name, path } of tests) { + test( + name, + asyncSafety(async () => { + /** + * The neovim client is set by the test runner in test-harness/src/index.ts into the global object. + * This allows us to access it in the tests that are executed through mocha. + */ + const client = (global as any).additionalParameters.client; + + const buffer = await fsp.readFile(path); + const fixture = yaml.load(buffer.toString()) as TestCaseFixtureLegacy; + if (!shouldRunTest(name, fixture)) { + return this.ctx.skip(); + } + + await runRecordedTest({ + path, + spyIde: getSpy()!, + openNewTestEditor: async (content: string, languageId: string) => { + return await openNewTestEditor(client, getNeovimIDE()!, content, { + languageId, + }); + }, + sleepWithBackoff, + testHelpers: (await getCursorlessApi()).testHelpers!, + runCursorlessCommand, + }); + }), + ); + } +}); + +// NOTE: When the nvim-data/swap folder gets too big, neovim will start +// displaying a "press enter or type command to continue" message for every ":enew" command +// so the workaround is to delete that folder. +async function openNewTestEditor( + client: NeovimClient, + neovimIDE: NeovimIDE, + content: string, + { openBeside = false }: NewEditorOptions = {}, +): Promise { + // open a new buffer + // @see: https://vi.stackexchange.com/questions/8345/a-built-in-way-to-make-vim-open-a-new-buffer-with-file + await client.command(":enew"); + + if (!openBeside) { + // close all the other buffers ( is needed because e# fails on unnamed buffers) + await client.command("execute '%bd!' | execute 'normal! \\'"); + } + + // standardise newlines so we can easily split the lines + const newLines = content.replace(/(?:\r\n|\r|\n)/g, "\n").split("\n"); + + // set the buffer contents + const window = await client.window; + const buffer = await window.buffer; + await buffer.setLines(newLines, { start: 0, end: -1, strictIndexing: false }); + + // Not sure it matters but we try to set the right end of line type + const eol = content.includes("\r\n") ? "CRLF" : "LF"; + // https://stackoverflow.com/questions/82726/convert-dos-windows-line-endings-to-linux-line-endings-in-vim + if (eol === "CRLF") { + await client.command(":set ff=dos"); + } else { + await client.command(":set ff=unix"); + } + + const editor = await neovimIDE.updateTextEditor(); + + return editor; +} diff --git a/packages/cursorless-neovim-e2e/tsconfig.json b/packages/cursorless-neovim-e2e/tsconfig.json new file mode 100644 index 0000000000..fa151fb52b --- /dev/null +++ b/packages/cursorless-neovim-e2e/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "out" + }, + "references": [ + { + "path": "../common" + }, + { + "path": "../neovim-common" + }, + { + "path": "../neovim-registry" + }, + { + "path": "../node-common" + }, + { + "path": "../test-case-recorder" + } + ], + "include": ["src/**/*.ts", "src/**/*.json", "../../typings/**/*.d.ts"] +} diff --git a/packages/cursorless-neovim/TERMINOLOGY.md b/packages/cursorless-neovim/TERMINOLOGY.md new file mode 100644 index 0000000000..a8fadf07cc --- /dev/null +++ b/packages/cursorless-neovim/TERMINOLOGY.md @@ -0,0 +1,7 @@ +# TextEditor/TextDocument vs Window/Buffer + +1. Each Cursorless "TextDocument" corresponds to a neovim "Buffer" +2. Each Cursorless "TextEditor" corresponds to a neovim "Window" +3. A "TextEditor" corresponds to a view of a "TextDocument". The same "TextDocument" can be opened in two different "TextEditor". +4. When a "Window" changes in neovim, we need to reflect its "TextEditor" +5. When a "Buffer" changes in neovim, we need to reflect its "TextDocument". diff --git a/packages/cursorless-neovim/package.json b/packages/cursorless-neovim/package.json new file mode 100644 index 0000000000..4252198b1e --- /dev/null +++ b/packages/cursorless-neovim/package.json @@ -0,0 +1,47 @@ +{ + "name": "@cursorless/cursorless-neovim", + "version": "1.0.0", + "description": "cursorless in neovim", + "main": "./out/index.cjs", + "private": true, + "scripts": { + "build": "pnpm run esbuild:prod && pnpm run populate-dist", + "compile": "tsc --build", + "esbuild:base": "esbuild ./src/index.ts --format=cjs --conditions=cursorless:bundler --bundle --outfile=./out/index.cjs --platform=node", + "esbuild": "pnpm run esbuild:base --sourcemap", + "esbuild:prod": "pnpm run esbuild:base --minify", + "populate-dist": "bash ./scripts/populate-dist.sh", + "populate-dist:prod": "bash ./scripts/populate-dist.sh", + "watch:tsc": "pnpm compile --watch", + "watch:esbuild": "pnpm esbuild --watch", + "watch": "pnpm run --filter @cursorless/cursorless-neovim --parallel '/^watch:.*/'", + "clean": "rm -rf ./out tsconfig.tsbuildinfo ./dist ./build" + }, + "keywords": [], + "author": "", + "license": "MIT", + "types": "./out/index.d.ts", + "exports": { + ".": { + "cursorless:bundler": "./src/index.ts", + "default": "./out/index.cjs" + } + }, + "dependencies": { + "@cursorless/common": "workspace:*", + "@cursorless/cursorless-engine": "workspace:*", + "@cursorless/neovim-common": "workspace:*", + "@cursorless/neovim-registry": "workspace:*", + "@cursorless/node-common": "workspace:*", + "@cursorless/test-case-recorder": "workspace:*" + }, + "devDependencies": { + "@types/chai": "^4.3.14", + "@types/js-yaml": "^4.0.9", + "@types/lodash": "4.17.0", + "@types/uuid": "^9.0.8", + "lodash": "^4.17.21", + "neovim": "5.1.0", + "vscode-uri": "^3.0.8" + } +} diff --git a/packages/cursorless-neovim/scripts/debug-neovim.bat b/packages/cursorless-neovim/scripts/debug-neovim.bat new file mode 100644 index 0000000000..1994f0f67c --- /dev/null +++ b/packages/cursorless-neovim/scripts/debug-neovim.bat @@ -0,0 +1,3 @@ +REM executing this batch script requires .bat to be registered to be opened by cmd.exe +REM which means no other software has overriden this +nvim -u %CURSORLESS_REPO_ROOT%/init.lua diff --git a/packages/cursorless-neovim/scripts/debug-neovim.sh b/packages/cursorless-neovim/scripts/debug-neovim.sh new file mode 100755 index 0000000000..58aae44dd6 --- /dev/null +++ b/packages/cursorless-neovim/scripts/debug-neovim.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +workspaceFolder="$1" +cursorless_mode="$2" + +export CURSORLESS_REPO_ROOT="${workspaceFolder}" +export NVIM_NODE_HOST_DEBUG="1" +export NVIM_NODE_LOG_FILE="${workspaceFolder}/packages/cursorless-neovim/out/nvim_node.log" +export NVIM_NODE_LOG_LEVEL="info" +export CURSORLESS_MODE="${cursorless_mode}" + +command nvim -u "${workspaceFolder}/init.lua" diff --git a/packages/cursorless-neovim/scripts/linux-terminal.sh b/packages/cursorless-neovim/scripts/linux-terminal.sh new file mode 100755 index 0000000000..912ed927e9 --- /dev/null +++ b/packages/cursorless-neovim/scripts/linux-terminal.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2068 +set -euo pipefail + +if command -v gnome-terminal &>/dev/null; then + # FIXME: Possibly have to use ;exec bash to not auto-close the terminal + gnome-terminal -- $@ +elif command -v gnome-console &>/dev/null; then + gnome-console -- $@ +elif command -v konsole &>/dev/null; then + konsole --hold -e $@ +elif command -v xfce4-terminal &>/dev/null; then + xfce4-terminal --hold -e $@ +elif command -v kitty &>/dev/null; then + kitty -1 --hold $@ +elif command -v alacritty &>/dev/null; then + alacritty --hold -e $@ +elif command -v wezterm &>/dev/null; then + wezterm --config "exit_behavior='Hold'" start --always-new-process $@ +else + echo "No supported terminal emulator found. File an issue to get it added." + exit 1 +fi diff --git a/packages/cursorless-neovim/scripts/populate-dist.sh b/packages/cursorless-neovim/scripts/populate-dist.sh new file mode 100755 index 0000000000..83de5f1f0d --- /dev/null +++ b/packages/cursorless-neovim/scripts/populate-dist.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "Populating dist directory..." +if [ ! -e "${CURSORLESS_REPO_ROOT-nonexistent}" ]; then + CURSORLESS_REPO_ROOT=$(git rev-parse --show-toplevel) +fi +echo "CURSORLESS_REPO_ROOT: $CURSORLESS_REPO_ROOT" +cursorless_nvim_dir="$CURSORLESS_REPO_ROOT/cursorless.nvim" +cursorless_neovim_node_in_dir="$CURSORLESS_REPO_ROOT/packages/cursorless-neovim" +test_harness_node_in_dir="$CURSORLESS_REPO_ROOT/packages/test-harness" + +if [[ "${CI:-x}" == "true" ]]; then + # If running in CI, only copy the necessary files for testing and release + cursorless_nvim_dist_dir="$CURSORLESS_REPO_ROOT/dist/cursorless.nvim" + mkdir -p "$cursorless_nvim_dist_dir" + + # copy static files such as .lua dependencies and command-server + cp -r "$cursorless_nvim_dir/"* "$cursorless_nvim_dist_dir" + + # Populate cursorless-neovim + cursorless_neovim_node_out_dir="$cursorless_nvim_dist_dir/node/cursorless-neovim" + mkdir -p "$cursorless_neovim_node_out_dir/out" + cp "$cursorless_neovim_node_in_dir/package.json" "$cursorless_neovim_node_out_dir" + cp "$cursorless_neovim_node_in_dir/out/index.cjs" "$cursorless_neovim_node_out_dir/out" + + # Populate test-harness + test_harness_node_out_dir="$cursorless_nvim_dist_dir/node/test-harness" + mkdir -p "$test_harness_node_out_dir/out" + cp "$test_harness_node_in_dir/package.json" "$test_harness_node_out_dir" + mkdir -p "$test_harness_node_out_dir/out/runners" + cp "$test_harness_node_in_dir/out/runners/extensionTestsNeovim.cjs" "$test_harness_node_out_dir/out/runners" +else + # Symlink so we inherit the .map files as well, but only if uname doesn't + # start with "MINGW" (Windows Git Bash) + # + # For Windows, you need Administrator privileges to create a symlink, so we + # assume it was created manually by the user during initial setup to avoid + # having to run vscode as Administrator + # https://github.com/orgs/community/discussions/23591 + # https://stackoverflow.com/questions/18641864/git-bash-shell-fails-to-create-symbolic-links/40914277#40914277 + if [[ "$(uname -s)" != MINGW* ]]; then + cursorless_neovim_node_out_dir="$cursorless_nvim_dir/node/cursorless-neovim" + rm -rf "$cursorless_neovim_node_out_dir" + ln -s "$cursorless_neovim_node_in_dir" "$cursorless_neovim_node_out_dir" + + test_harness_node_out_dir="$cursorless_nvim_dir/node/test-harness" + rm -rf "$test_harness_node_out_dir" + ln -s "$test_harness_node_in_dir" "$test_harness_node_out_dir" + fi +fi diff --git a/packages/cursorless-neovim/scripts/show-logs.sh b/packages/cursorless-neovim/scripts/show-logs.sh new file mode 100755 index 0000000000..7635219a01 --- /dev/null +++ b/packages/cursorless-neovim/scripts/show-logs.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +log_path="${CURSORLESS_REPO_ROOT}/packages/cursorless-neovim/out/nvim_node.log" + +# Be robust to the log file not existing yet +touch "${log_path}" + +echo "Logs will appear below once you start debugging Cursorless Neovim:" +tail -f "${log_path}" diff --git a/packages/cursorless-neovim/src/NeovimCommandServerApi.ts b/packages/cursorless-neovim/src/NeovimCommandServerApi.ts new file mode 100644 index 0000000000..dacccc01c1 --- /dev/null +++ b/packages/cursorless-neovim/src/NeovimCommandServerApi.ts @@ -0,0 +1,26 @@ +import { + CommandServerApi, + FocusedElementType, + InboundSignal, +} from "@cursorless/common"; +import type { NeovimClient } from "neovim/lib/api/client"; + +export class NeovimCommandServerApi implements CommandServerApi { + signals: { prePhrase: InboundSignal }; + + constructor(private client: NeovimClient) { + this.signals = { prePhrase: { getVersion: async () => null } }; + } + + // for vscode, it is actually stored into the command-server + // but for neovim, it is stored in cursorless + // https://github.com/pokey/command-server/blob/main/src/extension.ts#L32 + async getFocusedElementType(): Promise { + const currentMode = await this.client.mode; + if (currentMode.mode === "t" || currentMode.mode === "nt") { + return "terminal"; + } else { + return "textEditor"; + } + } +} diff --git a/packages/cursorless-neovim/src/constructTestHelpers.ts b/packages/cursorless-neovim/src/constructTestHelpers.ts new file mode 100644 index 0000000000..e723dbd878 --- /dev/null +++ b/packages/cursorless-neovim/src/constructTestHelpers.ts @@ -0,0 +1,73 @@ +import { + ExcludableSnapshotField, + ExtraSnapshotField, + FakeCommandServerApi, + HatTokenMap, + IDE, + NormalizedIDE, + ScopeProvider, + SerializedMarks, + StoredTargetKey, + TargetPlainObject, + TestCaseSnapshot, + TextEditor, +} from "@cursorless/common"; +import { + StoredTargetMap, + plainObjectToTarget, +} from "@cursorless/cursorless-engine"; +import { NeovimIDE, NeovimTestHelpers } from "@cursorless/neovim-common"; +import { takeSnapshot } from "@cursorless/test-case-recorder"; + +export function constructTestHelpers( + commandServerApi: FakeCommandServerApi, + storedTargets: StoredTargetMap, + hatTokenMap: HatTokenMap, + neovimIDE: NeovimIDE, + normalizedIde: NormalizedIDE, + scopeProvider: ScopeProvider, + injectIde: (ide: IDE) => void, + runIntegrationTests: () => Promise, +): NeovimTestHelpers | undefined { + return { + commandServerApi: commandServerApi!, + ide: normalizedIde, + neovimIDE, + injectIde, + scopeProvider, + + // FIXME: Remove this once we have a better way to get this function + // accessible from our tests + takeSnapshot( + excludeFields: ExcludableSnapshotField[], + extraFields: ExtraSnapshotField[], + editor: TextEditor, + ide: IDE, + marks: SerializedMarks | undefined, + ): Promise { + return takeSnapshot( + storedTargets, + excludeFields, + extraFields, + editor, + ide, + marks, + undefined, + undefined, + ); + }, + + setStoredTarget( + editor: TextEditor, + key: StoredTargetKey, + targets: TargetPlainObject[] | undefined, + ): void { + storedTargets.set( + key, + targets?.map((target) => plainObjectToTarget(editor, target)), + ); + }, + hatTokenMap, + runIntegrationTests, + }; +} diff --git a/packages/cursorless-neovim/src/extension.ts b/packages/cursorless-neovim/src/extension.ts new file mode 100644 index 0000000000..9516745b60 --- /dev/null +++ b/packages/cursorless-neovim/src/extension.ts @@ -0,0 +1,72 @@ +import { + FakeCommandServerApi, + FakeIDE, + NormalizedIDE, +} from "@cursorless/common"; +import { createCursorlessEngine } from "@cursorless/cursorless-engine"; +import { EXTENSION_ID, NeovimIDE } from "@cursorless/neovim-common"; +import { getNeovimRegistry } from "@cursorless/neovim-registry"; +import type { NeovimClient } from "neovim/lib/api/client"; +import type { NvimPlugin } from "neovim/lib/host/NvimPlugin"; +import { NeovimCommandServerApi } from "./NeovimCommandServerApi"; +import { constructTestHelpers } from "./constructTestHelpers"; +import { registerCommands } from "./registerCommands"; + +/** + * This function is called from cursorless.nvim to initialize the Cursorless engine. + * NOTE: this is not the cursorless-neovim extension entrypoint (which is called at Neovim startup) + * We named it activate() in order to have the same structure as the extension entrypoint to match cursorless-vscode + */ +export async function activate(plugin: NvimPlugin) { + const client = plugin.nvim as NeovimClient; + + const neovimIDE = new NeovimIDE(client); + await neovimIDE.init(); + + const normalizedIde = + neovimIDE.runMode === "production" + ? neovimIDE + : new NormalizedIDE( + neovimIDE, + new FakeIDE(), + neovimIDE.runMode === "test", + undefined, + ); + + const fakeCommandServerApi = new FakeCommandServerApi(); + const neovimCommandServerApi = new NeovimCommandServerApi(client); + const commandServerApi = + neovimIDE.runMode === "test" + ? fakeCommandServerApi + : neovimCommandServerApi; + + const { + commandApi, + storedTargets, + hatTokenMap, + scopeProvider, + injectIde, + runIntegrationTests, + } = await createCursorlessEngine({ ide: normalizedIde, commandServerApi }); + + await registerCommands(client, neovimIDE, commandApi, commandServerApi); + + const cursorlessApi = { + testHelpers: + neovimIDE.runMode === "test" + ? constructTestHelpers( + fakeCommandServerApi, + storedTargets, + hatTokenMap, + neovimIDE, + normalizedIde as NormalizedIDE, + scopeProvider, + injectIde, + runIntegrationTests, + ) + : undefined, + }; + getNeovimRegistry().registerExtensionApi(EXTENSION_ID, cursorlessApi); + + console.log("activate(): Cursorless extension loaded"); +} diff --git a/packages/cursorless-neovim/src/index.ts b/packages/cursorless-neovim/src/index.ts new file mode 100644 index 0000000000..354a4ba48e --- /dev/null +++ b/packages/cursorless-neovim/src/index.ts @@ -0,0 +1,36 @@ +import type { NvimPlugin } from "neovim"; +import { activate } from "./extension"; + +/** + * Extension entrypoint called by node-client on Neovim startup. + * - Register the functions that are exposed to Neovim. + * Note that these function need to start with a capital letter to be callable from Neovim. + */ +export default function entry(plugin: NvimPlugin) { + // We make sure the cursorless-neovim extension is only loaded once, + // as otherwise we will run our first copy when loading the extension + // and a different new copy for executing other functions + // At the moment, it doesn't matter that much because we don't call other functions + // That being said it doesn't hurt to set it to "false" anyway + // NOTE: this is the case because all the files are rolled up into a single index.cjs file + // and node-client would reload that index.cjs file if "dev" was set to "true" + plugin.setOptions({ dev: false }); + + plugin.registerFunction( + "CursorlessLoadExtension", + async () => await loadExtension(plugin), + { sync: false }, + ); +} + +/** + * Load the cursorless engine. + */ +async function loadExtension(plugin: NvimPlugin) { + console.log( + "===============================================================================================", + ); + console.log("loadExtension(cursorless-neovim): start"); + await activate(plugin); + console.log("loadExtension(cursorless-neovim): done"); +} diff --git a/packages/cursorless-neovim/src/registerCommands.ts b/packages/cursorless-neovim/src/registerCommands.ts new file mode 100644 index 0000000000..2dd389d1c9 --- /dev/null +++ b/packages/cursorless-neovim/src/registerCommands.ts @@ -0,0 +1,135 @@ +import { + CURSORLESS_COMMAND_ID, + CommandResponse, + CommandServerApi, + CursorlessCommandId, + clientSupportsFallback, + ensureCommandShape, +} from "@cursorless/common"; + +import { CommandApi } from "@cursorless/cursorless-engine"; +import { + NeovimIDE, + modeSwitchNormalTerminal, + modeSwitchTerminal, +} from "@cursorless/neovim-common"; +import { getNeovimRegistry } from "@cursorless/neovim-registry"; +import type { NeovimClient } from "neovim"; + +export async function registerCommands( + client: NeovimClient, + neovimIDE: NeovimIDE, + commandApi: CommandApi, + commandServerApi: CommandServerApi, +): Promise { + const commands: Record any> = { + // The core Cursorless command + [CURSORLESS_COMMAND_ID]: async (...args: unknown[]) => { + const originalMode = await client.mode; + if (originalMode.mode === "t") { + // Switch to "nt" so we can easily call lua functions without any problems + modeSwitchNormalTerminal(client); + } + + try { + await neovimIDE.updateTextEditor(); + const result = await commandApi.runCommandSafe(...args); + + const command = ensureCommandShape(args); + const focusedElementType = + await commandServerApi.getFocusedElementType(); + if ( + focusedElementType === "terminal" && + clientSupportsFallback(command) + ) { + const commandResponse = result as CommandResponse; + if ( + "fallback" in commandResponse && + commandResponse.fallback.action === "insert" + ) { + // if user runs a terminal, and a "bring" command was requested, switch back to "t" mode + // so the fallback can do its magic + modeSwitchTerminal(client); + } + } + + return result; + } catch (e) { + if (neovimIDE.runMode !== "test") { + const err = e as Error; + console.error(err.stack); + neovimIDE.handleCommandError(err); + } + throw e; + } + }, + + ["cursorless.repeatPreviousCommand"]: dummyCommandHandler, + + // Cheatsheet commands + ["cursorless.showCheatsheet"]: dummyCommandHandler, + ["cursorless.internal.updateCheatsheetDefaults"]: dummyCommandHandler, + + // Testcase recorder commands + ["cursorless.recordTestCase"]: dummyCommandHandler, + ["cursorless.recordOneTestCaseThenPause"]: dummyCommandHandler, + ["cursorless.pauseRecording"]: dummyCommandHandler, + ["cursorless.resumeRecording"]: dummyCommandHandler, + ["cursorless.takeSnapshot"]: dummyCommandHandler, + + // Scope test recorder commands + ["cursorless.recordScopeTests.showUnimplementedFacets"]: + dummyCommandHandler, + ["cursorless.recordScopeTests.saveActiveDocument"]: dummyCommandHandler, + + // Other commands + ["cursorless.showQuickPick"]: dummyCommandHandler, + ["cursorless.showDocumentation"]: dummyCommandHandler, + ["cursorless.private.logQuickActions"]: dummyCommandHandler, + + // Hats + ["cursorless.toggleDecorations"]: dummyCommandHandler, + ["cursorless.recomputeDecorationStyles"]: dummyCommandHandler, + + // Scope visualizer + ["cursorless.showScopeVisualizer"]: dummyCommandHandler, + ["cursorless.hideScopeVisualizer"]: dummyCommandHandler, + + // Command history + ["cursorless.analyzeCommandHistory"]: dummyCommandHandler, + + // General keyboard commands + ["cursorless.keyboard.escape"]: dummyCommandHandler, + + // Targeted keyboard commands + ["cursorless.keyboard.targeted.targetHat"]: dummyCommandHandler, + ["cursorless.keyboard.targeted.targetScope"]: dummyCommandHandler, + ["cursorless.keyboard.targeted.targetSelection"]: dummyCommandHandler, + ["cursorless.keyboard.targeted.clearTarget"]: dummyCommandHandler, + ["cursorless.keyboard.targeted.runActionOnTarget"]: dummyCommandHandler, + + // Modal keyboard commands + ["cursorless.keyboard.modal.modeOn"]: dummyCommandHandler, + ["cursorless.keyboard.modal.modeOff"]: dummyCommandHandler, + ["cursorless.keyboard.modal.modeToggle"]: dummyCommandHandler, + ["cursorless.keyboard.undoTarget"]: dummyCommandHandler, + ["cursorless.keyboard.redoTarget"]: dummyCommandHandler, + + // Tutorial commands + ["cursorless.tutorial.start"]: dummyCommandHandler, + ["cursorless.tutorial.next"]: dummyCommandHandler, + ["cursorless.tutorial.previous"]: dummyCommandHandler, + ["cursorless.tutorial.restart"]: dummyCommandHandler, + ["cursorless.tutorial.resume"]: dummyCommandHandler, + ["cursorless.tutorial.list"]: dummyCommandHandler, + ["cursorless.documentationOpened"]: dummyCommandHandler, + }; + + Object.entries(commands).map(([commandId, callback]) => + getNeovimRegistry().registerCommand(commandId, callback), + ); +} + +export async function dummyCommandHandler(...args: any[]) { + console.debug(`dummyCommandHandler(): args=${args}`); +} diff --git a/packages/cursorless-neovim/tsconfig.json b/packages/cursorless-neovim/tsconfig.json new file mode 100644 index 0000000000..ebb84f2ad6 --- /dev/null +++ b/packages/cursorless-neovim/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "out" + }, + "references": [ + { + "path": "../common" + }, + { + "path": "../cursorless-engine" + }, + { + "path": "../neovim-common" + }, + { + "path": "../neovim-registry" + }, + { + "path": "../node-common" + }, + { + "path": "../test-case-recorder" + } + ], + "include": ["src/**/*.ts", "src/**/*.json", "../../typings/**/*.d.ts"] +} diff --git a/packages/cursorless-org-docs/docusaurus.config.mts b/packages/cursorless-org-docs/docusaurus.config.mts index 694c7b1f0d..20ca4f7d55 100644 --- a/packages/cursorless-org-docs/docusaurus.config.mts +++ b/packages/cursorless-org-docs/docusaurus.config.mts @@ -159,7 +159,7 @@ const config: Config = { prism: { theme: themes.github, darkTheme: themes.dracula, - additionalLanguages: ["bash", "diff", "json", "python"], + additionalLanguages: ["bash", "diff", "json", "python", "lua"], }, colorMode: { respectPrefersColorScheme: true, diff --git a/packages/cursorless-vscode/src/extension.ts b/packages/cursorless-vscode/src/extension.ts index a8664ab471..ffefeed17a 100644 --- a/packages/cursorless-vscode/src/extension.ts +++ b/packages/cursorless-vscode/src/extension.ts @@ -97,6 +97,8 @@ export async function activate( const treeSitter = createTreeSitter(parseTreeApi); const talonSpokenForms = new FileSystemTalonSpokenForms(fileSystem); + // NOTE: do not await on snippet loading and hats initialization because we don't want to + // block extension activation const snippets = new VscodeSnippets(normalizedIde); void snippets.init(); diff --git a/packages/meta-updater/src/updatePackageJson.ts b/packages/meta-updater/src/updatePackageJson.ts index 84929d8009..54db28b6f2 100644 --- a/packages/meta-updater/src/updatePackageJson.ts +++ b/packages/meta-updater/src/updatePackageJson.ts @@ -76,7 +76,11 @@ export async function updatePackageJson( ...input, name, license: "MIT", - type: name === "@cursorless/cursorless-org-docs" ? undefined : "module", + type: + name === "@cursorless/cursorless-org-docs" || + name === "@cursorless/cursorless-neovim" + ? undefined + : "module", scripts: await getScripts(input.scripts, name, packageDir, isRoot, isLib), ...exportFields, ...extraFields, diff --git a/packages/neovim-common/package.json b/packages/neovim-common/package.json new file mode 100644 index 0000000000..93ef8ee7f6 --- /dev/null +++ b/packages/neovim-common/package.json @@ -0,0 +1,37 @@ +{ + "name": "@cursorless/neovim-common", + "version": "1.0.0", + "description": "Common utility functions usable anywhere that neovim api is available", + "main": "./out/index.js", + "scripts": { + "compile:tsc": "tsc --build", + "compile:esbuild": "esbuild ./src/index.ts --sourcemap --format=esm --bundle --packages=external --outfile=./out/index.js", + "compile": "pnpm compile:tsc && pnpm compile:esbuild", + "watch:tsc": "pnpm compile:tsc --watch", + "watch:esbuild": "pnpm compile:esbuild --watch", + "watch": "pnpm run --filter @cursorless/neovim-common --parallel '/^watch:.*/'", + "clean": "rm -rf ./out tsconfig.tsbuildinfo ./dist ./build" + }, + "keywords": [], + "author": "", + "license": "MIT", + "types": "./out/index.d.ts", + "exports": { + ".": { + "cursorless:bundler": "./src/index.ts", + "default": "./out/index.js" + } + }, + "dependencies": { + "@cursorless/common": "workspace:*", + "@cursorless/neovim-registry": "workspace:*", + "@cursorless/node-common": "workspace:*", + "@types/lodash": "4.17.0", + "@types/uuid": "^9.0.8", + "@types/vscode": "1.75.1", + "lodash": "^4.17.21", + "neovim": "5.1.0", + "vscode-uri": "^3.0.8" + }, + "type": "module" +} diff --git a/packages/neovim-common/src/TestHelpers.ts b/packages/neovim-common/src/TestHelpers.ts new file mode 100644 index 0000000000..1a789cd3cb --- /dev/null +++ b/packages/neovim-common/src/TestHelpers.ts @@ -0,0 +1,17 @@ +import type { + IDE, + NormalizedIDE, + ScopeProvider, + TestHelpers, +} from "@cursorless/common"; +import { NeovimIDE } from "./ide/neovim/NeovimIDE"; + +export interface NeovimTestHelpers extends TestHelpers { + ide: NormalizedIDE; + neovimIDE: NeovimIDE; + injectIde: (ide: IDE) => void; + + scopeProvider: ScopeProvider; + + runIntegrationTests(): Promise; +} diff --git a/packages/neovim-common/src/getExtensionApi.ts b/packages/neovim-common/src/getExtensionApi.ts new file mode 100644 index 0000000000..12ca15a22a --- /dev/null +++ b/packages/neovim-common/src/getExtensionApi.ts @@ -0,0 +1,53 @@ +import type { SnippetMap } from "@cursorless/common"; +//import * as vscode from "vscode"; +import { getNeovimRegistry } from "@cursorless/neovim-registry"; +import { NeovimTestHelpers } from "./TestHelpers"; + +export interface CursorlessApi { + testHelpers: NeovimTestHelpers | undefined; + + experimental: { + registerThirdPartySnippets: ( + extensionId: string, + snippets: SnippetMap, + ) => void; + }; +} + +// See packages\cursorless-neovim\src\extension.ts:createTreeSitter() for neovim +// export interface ParseTreeApi { +// getNodeAtLocation(location: vscode.Location): SyntaxNode; +// getTreeForUri(uri: vscode.Uri): Tree; +// loadLanguage: (languageId: string) => Promise; +// getLanguage(languageId: string): Language | undefined; +// } + +export async function getExtensionApi(extensionId: string) { + const api = getNeovimRegistry().getExtensionApi(extensionId); + return api == null ? null : (api as T); +} + +export async function getExtensionApiStrict(extensionId: string) { + const api = getNeovimRegistry().getExtensionApi(extensionId); + + if (api == null) { + throw new Error(`Could not get ${extensionId} extension`); + } + + return api as T; +} + +export const EXTENSION_ID = "pokey.cursorless"; +export const getCursorlessApi = () => + getExtensionApiStrict(EXTENSION_ID); + +// export const getParseTreeApi = () => +// getExtensionApiStrict("pokey.parse-tree"); + +// See packages/cursorless-neovim/src/NeovimCommandServerApi.ts for neovim implementation +/** + * + * @returns Command server API or null if not installed + */ +// export const getCommandServerApi = () => +// getExtensionApi("pokey.command-server"); diff --git a/packages/neovim-common/src/ide/neovim/NeovimCapabilities.ts b/packages/neovim-common/src/ide/neovim/NeovimCapabilities.ts new file mode 100644 index 0000000000..5b9308b60f --- /dev/null +++ b/packages/neovim-common/src/ide/neovim/NeovimCapabilities.ts @@ -0,0 +1,24 @@ +import { Capabilities, CommandCapabilityMap } from "@cursorless/common"; + +const COMMAND_CAPABILITIES: CommandCapabilityMap = { + clipboardCopy: { acceptsLocation: false }, + toggleLineComment: undefined, + indentLine: undefined, + outdentLine: undefined, + rename: undefined, + quickFix: undefined, + revealDefinition: undefined, + revealTypeDefinition: undefined, + showHover: undefined, + showDebugHover: undefined, + extractVariable: undefined, + fold: undefined, + highlight: { acceptsLocation: true }, + unfold: undefined, + showReferences: undefined, + insertLineAfter: undefined, +}; + +export class NeovimCapabilities implements Capabilities { + commands = COMMAND_CAPABILITIES; +} diff --git a/packages/neovim-common/src/ide/neovim/NeovimClipboard.ts b/packages/neovim-common/src/ide/neovim/NeovimClipboard.ts new file mode 100644 index 0000000000..5aa6080829 --- /dev/null +++ b/packages/neovim-common/src/ide/neovim/NeovimClipboard.ts @@ -0,0 +1,15 @@ +import type { Clipboard } from "@cursorless/common"; +import { getClipboard, setClipboard } from "../../neovimApi"; +import type { NeovimClient } from "neovim"; + +export default class NeovimClipboard implements Clipboard { + constructor(private client: NeovimClient) {} + + async readText(): Promise { + return await getClipboard(this.client); + } + + async writeText(value: string): Promise { + return await setClipboard(value, this.client); + } +} diff --git a/packages/neovim-common/src/ide/neovim/NeovimConfiguration.ts b/packages/neovim-common/src/ide/neovim/NeovimConfiguration.ts new file mode 100644 index 0000000000..bd8bf7e24f --- /dev/null +++ b/packages/neovim-common/src/ide/neovim/NeovimConfiguration.ts @@ -0,0 +1,73 @@ +import { get } from "lodash"; +import { Notifier } from "@cursorless/common"; +import { + Configuration, + ConfigurationScope, + CONFIGURATION_DEFAULTS, + CursorlessConfigKey, + CursorlessConfiguration, +} from "@cursorless/common"; +import { GetFieldType, Paths } from "@cursorless/common"; + +interface ConfigurationScopeValues { + scope: ConfigurationScope; + values: Partial; +} + +export default class NeovimConfiguration implements Configuration { + private notifier = new Notifier(); + private mocks: CursorlessConfiguration = { + ...CONFIGURATION_DEFAULTS, + }; + private scopes: ConfigurationScopeValues[] = []; + + constructor() { + this.onDidChangeConfiguration = this.onDidChangeConfiguration.bind(this); + } + + getOwnConfiguration>( + path: Path, + scope?: ConfigurationScope, + ): GetFieldType { + if (scope != null) { + for (const { scope: candidateScope, values } of this.scopes) { + if (scopeMatches(candidateScope, scope)) { + return (get(values, path) ?? get(this.mocks, path)) as GetFieldType< + CursorlessConfiguration, + Path + >; + } + } + } + + return get(this.mocks, path) as GetFieldType; + } + + onDidChangeConfiguration = this.notifier.registerListener; + + mockConfiguration( + key: T, + value: CursorlessConfiguration[T], + ): void { + this.mocks[key] = value; + this.notifier.notifyListeners(); + } + + mockConfigurationScope( + scope: ConfigurationScope, + values: Partial, + noNotification: boolean = false, + ): void { + this.scopes.push({ scope, values }); + if (!noNotification) { + this.notifier.notifyListeners(); + } + } +} + +function scopeMatches( + candidateScope: ConfigurationScope, + scope: ConfigurationScope, +): boolean { + return candidateScope.languageId === scope.languageId; +} diff --git a/packages/neovim-common/src/ide/neovim/NeovimEdit.ts b/packages/neovim-common/src/ide/neovim/NeovimEdit.ts new file mode 100644 index 0000000000..208c2e544a --- /dev/null +++ b/packages/neovim-common/src/ide/neovim/NeovimEdit.ts @@ -0,0 +1,227 @@ +import { + Edit, + Position, + Range, + TextDocument, + TextDocumentContentChangeEvent, +} from "@cursorless/common"; +import type { NeovimClient, Window } from "neovim"; +import { NeovimIDE } from "./NeovimIDE"; +import { getNeovimRegistry } from "@cursorless/neovim-registry"; + +export default async function neovimEdit( + client: NeovimClient, + neovimIDE: NeovimIDE, + window: Window, + edits: Edit[], +): Promise { + console.debug("neovimEdit() [unsorted]:"); + for (const edit of edits) { + console.debug( + `\trange=${JSON.stringify(edit.range)}, text='${edit.text}', isReplace=${edit.isReplace}`, + ); + } + + edits.sort((a, b) => { + // console.debug( + // `a=${JSON.stringify(a.range)}, text='${a.text}', isReplace=${a.isReplace}`, + // ); + // console.debug( + // `b=${JSON.stringify(b.range)}, text='${b.text}', isReplace=${b.isReplace}`, + // ); + // We apply the insert/replace edits from the start of the document + // as a later one assume the previous ones have already been applied + if ((isInsert(a) || isReplace(a)) && (isInsert(b) || isReplace(b))) { + // console.debug("a is insert/replace and b is insert/replace"); + return 1; + } + // We apply the delete edits from the end of the document + // to make sure the edit ranges for the remaining ones are stable + if (a.range.start.line === b.range.start.line) { + // console.debug("a and b are on the same line"); + return b.range.start.character - a.range.start.character; + } + // console.debug("a and b are on different lines"); + return b.range.start.line - a.range.start.line; + }); + + console.debug("neovimEdit() [sorted]:"); + for (const edit of edits) { + console.debug( + `\trange=${JSON.stringify(edit.range)}, text='${edit.text}', isReplace=${edit.isReplace}`, + ); + } + const document = neovimIDE.getTextDocument( + await client.window.buffer, + ) as TextDocument; + const changes: TextDocumentContentChangeEvent[] = []; + for (const edit of edits) { + changes.push({ + range: edit.range, + rangeOffset: document.offsetAt(edit.range.start), + rangeLength: + document.offsetAt(edit.range.end) - document.offsetAt(edit.range.start), + text: edit.text, + }); + } + + getNeovimRegistry().emitEvent("onDidChangeTextDocument", { + document: document, + contentChanges: changes, + }); + + for (const edit of edits) { + const { range, text, isReplace } = edit; + + if (text === "") { + await neovimDelete(client, range); + } else if (range.isEmpty && !isReplace) { + await neovimInsert(client, range.start, text); + } else { + await neovimReplace(client, range, text); + } + } + + await neovimIDE.updateTextEditor(); + return true; +} + +async function neovimDelete(client: NeovimClient, range: Range): Promise { + console.debug(`neovimDelete(): range=${JSON.stringify(range)}`); + const buffer = await client.window.buffer; + + // only keep the end of the last line + const lastLine = ( + await buffer.getLines({ + start: range.end.line, + end: range.end.line + 1, + strictIndexing: true, + }) + )[0]; + const endOfLastLine = lastLine.slice(range.end.character); + + // are we only modifying one existing line? + if (range.start.line === range.end.line) { + // only keep the beginning and end of the line + const singleLine = lastLine.slice(0, range.start.character) + endOfLastLine; + // update that single line + await buffer.setLines(singleLine, { + start: range.start.line, + end: range.start.line + 1, + strictIndexing: true, + }); + return; + } + + // we are modifying multiple lines + + // are we not including the beginning of the first line? + if (range.start.character === 0) { + // if we are deleting from the start of the first line, we need to exclude the first line + await buffer.setLines(endOfLastLine, { + start: range.start.line, + end: range.end.line + 1, + strictIndexing: true, + }); + return; + } + + // only keep the beginning of the first line + const firstLine = ( + await buffer.getLines({ + start: range.start.line, + end: range.start.line + 1, + strictIndexing: true, + }) + )[0]; + const startOfFirstLine = firstLine.slice(0, range.start.character); + + // are we not including the newline at the end of the first line? + if (range.start.character <= firstLine.length) { + // if we are deleting from before the end of the first line, we need to append the last line to the first line + await buffer.setLines(startOfFirstLine + endOfLastLine, { + start: range.start.line, + end: range.end.line + 1, + strictIndexing: true, + }); + return; + } + + await buffer.setLines([startOfFirstLine, endOfLastLine], { + start: range.start.line, + end: range.end.line + 1, + strictIndexing: true, + }); +} + +async function neovimInsert( + client: NeovimClient, + position: Position, + text: string, +) { + console.debug( + `neovimInsert(): position=${JSON.stringify(position)}, text='${text}'`, + ); + // standardise newlines so we can easily split the lines + const newLines = text.replace(/(?:\r\n|\r|\n)/g, "\n").split("\n"); + + const buffer = await client.window.buffer; + + const lineWhereInsertion = ( + await buffer.getLines({ + start: position.line, + end: position.line + 1, + strictIndexing: true, + }) + )[0]; + const startOfFirstLine = lineWhereInsertion.slice(0, position.character); + const endOfLastLine = lineWhereInsertion.slice(position.character); + + // are we only inserting into one existing line? + if (newLines.length === 1) { + const singleLine = startOfFirstLine + newLines[0] + endOfLastLine; + // update that single line + await buffer.setLines(singleLine, { + start: position.line, + end: position.line + 1, + strictIndexing: true, + }); + return; + } + + // we are inserting multiple lines + + const firstLine = startOfFirstLine + newLines[0]; + const lastLine = newLines[newLines.length - 1] + endOfLastLine; + await buffer.setLines( + [firstLine, ...newLines.slice(1, newLines.length - 1), lastLine], + { + start: position.line, + end: position.line + 1, + strictIndexing: true, + }, + ); +} + +async function neovimReplace(client: NeovimClient, range: Range, text: string) { + console.debug( + `neovimReplace(): range=${JSON.stringify(range)}, text='${text}'`, + ); + await neovimDelete(client, range); + await neovimInsert(client, range.start, text); +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function isDelete(edit: Edit): boolean { + return edit.text === ""; +} + +function isInsert(edit: Edit): boolean { + return edit.range.isEmpty && !edit.isReplace; +} + +function isReplace(edit: Edit): boolean { + return ( + edit.text !== "" && (!edit.range.isEmpty || edit.isReplace ? true : false) + ); +} diff --git a/packages/neovim-common/src/ide/neovim/NeovimEvents.ts b/packages/neovim-common/src/ide/neovim/NeovimEvents.ts new file mode 100644 index 0000000000..ed42b8d21c --- /dev/null +++ b/packages/neovim-common/src/ide/neovim/NeovimEvents.ts @@ -0,0 +1,63 @@ +import { + Disposable, + Position, + Range, + TextDocument, + TextDocumentChangeEvent, + TextDocumentContentChangeEvent, +} from "@cursorless/common"; +import { getNeovimRegistry } from "@cursorless/neovim-registry"; + +import type { Buffer } from "neovim"; + +export function neovimOnDidChangeTextDocument( + listener: (event: TextDocumentChangeEvent) => void, +): Disposable { + getNeovimRegistry().onEvent("onDidChangeTextDocument", listener); + return dummyEvent(); +} + +export function neovimOnDidOpenTextDocument( + listener: (event: TextDocument) => any, + _thisArgs?: any, + _disposables?: Disposable[] | undefined, +): Disposable { + getNeovimRegistry().onEvent("onDidOpenTextDocument", listener); + return dummyEvent(); +} + +export function fromNeovimContentChange( + document: TextDocument, + buffer: Buffer, + firstLine: number, + lastLine: number, + linedata: string[], +): TextDocumentContentChangeEvent[] { + const result = []; + const text = linedata.join("\n"); + console.debug( + `fromNeovimContentChange(): document.getText(): '${document.getText()}'`, + ); + const range = new Range( + new Position(firstLine, 0), + new Position(lastLine - 1, document.lineAt(lastLine - 1).text.length), + ); + const rangeOffset = document.offsetAt(range.start); + const rangeLength = document.offsetAt(range.end) - rangeOffset; + result.push({ + range: range, + rangeOffset: rangeOffset, + rangeLength: rangeLength, + text: text, + }); + console.debug(`fromNeovimContentChange(): changes=${JSON.stringify(result)}`); + return result; +} + +function dummyEvent() { + return { + dispose() { + // empty + }, + }; +} diff --git a/packages/neovim-common/src/ide/neovim/NeovimGlobalState.ts b/packages/neovim-common/src/ide/neovim/NeovimGlobalState.ts new file mode 100644 index 0000000000..f852288141 --- /dev/null +++ b/packages/neovim-common/src/ide/neovim/NeovimGlobalState.ts @@ -0,0 +1,15 @@ +import type { State, StateData, StateKey } from "@cursorless/common"; +import { STATE_DEFAULTS } from "@cursorless/common"; + +export default class NeovimGlobalState implements State { + private readonly data: StateData = { ...STATE_DEFAULTS }; + + get(key: K): StateData[K] { + return this.data[key]; + } + + set(key: K, value: StateData[K]): Promise { + this.data[key] = value; + return Promise.resolve(); + } +} diff --git a/packages/neovim-common/src/ide/neovim/NeovimIDE.ts b/packages/neovim-common/src/ide/neovim/NeovimIDE.ts new file mode 100644 index 0000000000..ae41b6047a --- /dev/null +++ b/packages/neovim-common/src/ide/neovim/NeovimIDE.ts @@ -0,0 +1,337 @@ +import type { + Disposable, + EditableTextEditor, + IDE, + OpenUntitledTextDocumentOptions, + Range, + RunMode, + Selection, + TextDocumentChangeEvent, + TextEditor, + WorkspaceFolder, +} from "@cursorless/common"; +import { + Event, + FlashDescriptor, + GeneralizedRange, + QuickPickOptions, + TextDocument, + TextEditorSelectionChangeEvent, + TextEditorVisibleRangesChangeEvent, +} from "@cursorless/common"; +import { pull } from "lodash"; +import type { Buffer, NeovimClient, Window } from "neovim"; +import { v4 as uuid } from "uuid"; +import { NeovimCapabilities } from "./NeovimCapabilities"; +import NeovimClipboard from "./NeovimClipboard"; +import NeovimConfiguration from "./NeovimConfiguration"; +import NeovimGlobalState from "./NeovimGlobalState"; +import NeovimMessages from "./NeovimMessages"; +import { NeovimTextEditorImpl } from "./NeovimTextEditorImpl"; +import path from "path"; +import { URI } from "vscode-uri"; +import { nodeGetRunMode } from "@cursorless/node-common"; + +import { + bufferGetSelections, + getCursorlessNvimPath, + showErrorMessage, + windowGetVisibleRanges, +} from "../../neovimApi"; +import { + neovimOnDidChangeTextDocument, + neovimOnDidOpenTextDocument, +} from "./NeovimEvents"; +import { NeovimTextDocumentImpl } from "./NeovimTextDocumentImpl"; +import { getNeovimRegistry } from "@cursorless/neovim-registry"; + +export class NeovimIDE implements IDE { + readonly configuration: NeovimConfiguration; + readonly globalState: NeovimGlobalState; + readonly messages: NeovimMessages; + readonly clipboard: NeovimClipboard; + readonly capabilities: NeovimCapabilities; + private editorMap; + private documentMap; + private activeWindow: Window | undefined; + private activeBuffer: Buffer | undefined; + + cursorlessVersion: string = "0.0.0"; + workspaceFolders: readonly WorkspaceFolder[] | undefined = undefined; + private disposables: Disposable[] = []; + private assetsRoot_: string | undefined; + private cursorlessNeovimPath: string | undefined; + private quickPickReturnValue: string | undefined = undefined; + + constructor(private client: NeovimClient) { + this.configuration = new NeovimConfiguration(); + this.globalState = new NeovimGlobalState(); + this.messages = new NeovimMessages(); + this.clipboard = new NeovimClipboard(this.client); + this.capabilities = new NeovimCapabilities(); + this.editorMap = new Map(); + this.documentMap = new Map(); + this.activeWindow = undefined; + this.activeBuffer = undefined; + } + + async init() { + const rootPath = await getCursorlessNvimPath(this.client); + // we store the assets into a subfolder of cursorless.nvim + this.assetsRoot_ = path.join(rootPath, "assets"); + this.cursorlessNeovimPath = path.join( + rootPath, + "node", + "cursorless-neovim", + ); + } + + async showQuickPick( + _items: readonly string[], + _options?: QuickPickOptions, + ): Promise { + throw Error("showQuickPick Not implemented"); + } + + async setHighlightRanges( + _highlightId: string | undefined, + _editor: TextEditor, + _ranges: GeneralizedRange[], + ): Promise { + throw Error("setHighlightRanges Not implemented"); + } + + async flashRanges(_flashDescriptors: FlashDescriptor[]): Promise { + console.debug("flashRanges Not implemented"); + } + + get assetsRoot(): string { + if (this.assetsRoot_ == null) { + throw Error("Field `assetsRoot` has not yet been mocked"); + } + + return this.assetsRoot_; + } + + // + get runMode(): RunMode { + return nodeGetRunMode(); + } + + get activeTextEditor(): TextEditor | undefined { + // throw Error("activeTextEditor Not implemented"); + return this.getActiveTextEditor(); + } + + get activeEditableTextEditor(): EditableTextEditor | undefined { + // throw Error("activeEditableTextEditor Not implemented"); + return this.getActiveTextEditor(); + } + + private getActiveTextEditor() { + const editor = this.activeWindow + ? this.getTextEditor(this.activeWindow) + : undefined; + if (editor === undefined) { + console.debug("getActiveTextEditor: editor is undefined"); + } + return editor; + } + + private getTextEditor(w: Window) { + for (const [window, textEditor] of this.editorMap) { + if (window.id === w.id) { + return textEditor; + } + } + return undefined; + } + + public getTextDocument(b: Buffer) { + for (const [buffer, textDocument] of this.documentMap) { + if (buffer.id === b.id) { + return textDocument; + } + } + return undefined; + } + + get visibleTextEditors(): NeovimTextEditorImpl[] { + return Array.from(this.editorMap.values()); + // throw Error("visibleTextEditors Not implemented"); + } + + public getEditableTextEditor(editor: TextEditor): EditableTextEditor { + return editor as EditableTextEditor; + // throw Error("getEditableTextEditor Not implemented"); + } + + public async findInDocument( + _query: string, + _editor: TextEditor, + ): Promise { + throw Error("findInDocument Not implemented"); + } + + public async findInWorkspace(_query: string): Promise { + throw Error("findInWorkspace Not implemented"); + } + + public async openTextDocument(_path: string): Promise { + throw Error("openTextDocument Not implemented"); + } + + public async openUntitledTextDocument( + _options: OpenUntitledTextDocumentOptions, + ): Promise { + throw Error("openUntitledTextDocument Not implemented"); + } + + public async showInputBox(_options?: any): Promise { + throw Error("TextDocumentChangeEvent Not implemented"); + } + + public async executeCommand( + _command: string, + ..._args: any[] + ): Promise { + throw new Error("executeCommand Method not implemented."); + } + + public onDidChangeTextDocument( + listener: (event: TextDocumentChangeEvent) => void, + ): Disposable { + return neovimOnDidChangeTextDocument(listener); + } + + public onDidOpenTextDocument( + listener: (event: TextDocument) => any, + thisArgs?: any, + disposables?: Disposable[] | undefined, + ): Disposable { + return neovimOnDidOpenTextDocument(listener, thisArgs, disposables); + } + onDidCloseTextDocument: Event = dummyEvent; + onDidChangeActiveTextEditor: Event = dummyEvent; + onDidChangeVisibleTextEditors: Event = dummyEvent; + onDidChangeTextEditorSelection: Event = + dummyEvent; + onDidChangeTextEditorVisibleRanges: Event = + dummyEvent; + + /** + * Initialize the current editor (and current document). + * If the current editor already exists, it will only update the current document of that editor. + * + * when we receive our first cursorless command, we will initialize an editor an document for it. + * for the following commands, we will only update the document. + * + * Atm, we only initialize one editor(current window) with one document(current buffer) + */ + async updateTextEditor( + minimal: boolean = false, + ): Promise { + const window = await this.client.window; + const buffer = await window.buffer; + const lines = await buffer.lines; + let linesShown = lines; + if (lines.length >= 30) { + linesShown = lines.slice(0, 15).concat(["..."]).concat(lines.slice(-15)); + } + console.debug( + `updateTextEditor(): window:${window.id}, buffer:${buffer.id}, lines=${JSON.stringify(linesShown)}`, + ); + let selections: Selection[]; + let visibleRanges: Range[]; + if (!minimal) { + selections = await bufferGetSelections(window, this.client); + visibleRanges = await windowGetVisibleRanges(window, this.client, lines); + } else { + selections = []; + visibleRanges = []; + } + const editor = this.toNeovimEditor( + window, + buffer, + lines, + visibleRanges, + selections, + ); + + getNeovimRegistry().emitEvent("onDidOpenTextDocument", editor.document); + + return editor; + } + + toNeovimEditor( + window: Window, + buffer: Buffer, + lines: string[], + visibleRanges: Range[], + selections: Selection[], + ): NeovimTextEditorImpl { + let document = this.getTextDocument(buffer); + let editor = this.getTextEditor(window); + if (!document) { + console.debug( + `toNeovimEditor(): creating new document: buffer=${buffer.id}`, + ); + document = new NeovimTextDocumentImpl( + URI.parse(`neovim://${buffer.id}`), // URI.parse(`file://${buffer.id}`), + "plaintext", + 1, + "\n", + // "\r\n", + lines, + ); + this.documentMap.set(buffer, document); + } else { + console.debug(`toNeovimEditor(): updating document: buffer=${buffer.id}`); + document.update(lines); + } + if (!editor) { + console.debug( + `toNeovimEditor(): creating new editor: window=${window.id}`, + ); + editor = new NeovimTextEditorImpl( + uuid(), + this.client, + this, + window, + document, + visibleRanges, + selections, + ); + this.editorMap.set(window, editor); + } else { + console.debug(`toNeovimEditor(): updating editor: window=${window.id}`); + editor.updateDocument(visibleRanges, selections, document); + } + this.activeBuffer = buffer; + this.activeWindow = window; + + return this.activeTextEditor as NeovimTextEditorImpl; + } + + handleCommandError(err: Error) { + // if (err instanceof OutdatedExtensionError) { + // this.showUpdateExtensionErrorMessage(err); + // } else { + showErrorMessage(this.client, err.message); + // } + } + + disposeOnExit(...disposables: Disposable[]): () => void { + this.disposables.push(...disposables); + + return () => pull(this.disposables, ...disposables); + } +} + +function dummyEvent() { + return { + dispose() { + // empty + }, + }; +} diff --git a/packages/neovim-common/src/ide/neovim/NeovimMessages.ts b/packages/neovim-common/src/ide/neovim/NeovimMessages.ts new file mode 100644 index 0000000000..ee71484c44 --- /dev/null +++ b/packages/neovim-common/src/ide/neovim/NeovimMessages.ts @@ -0,0 +1,12 @@ +import type { MessageId, Messages, MessageType } from "@cursorless/common"; + +export default class NeovimMessages implements Messages { + async showMessage( + _type: MessageType, + _id: MessageId, + _message: string, + ..._options: string[] + ): Promise { + return undefined; + } +} diff --git a/packages/neovim-common/src/ide/neovim/NeovimTextDocumentImpl.ts b/packages/neovim-common/src/ide/neovim/NeovimTextDocumentImpl.ts new file mode 100644 index 0000000000..454e9c3a7a --- /dev/null +++ b/packages/neovim-common/src/ide/neovim/NeovimTextDocumentImpl.ts @@ -0,0 +1,391 @@ +import { + EndOfLine, + Position, + Range, + TextDocument, + TextLine, +} from "@cursorless/common"; +import { URI } from "vscode-uri"; +import NeovimTextLineImpl from "./NeovimTextLineImpl"; +import path from "node:path"; + +export class NeovimTextDocumentImpl implements TextDocument { + private _uri: URI; + private _languageId: string; + private _version: number; + private _lineCount: number; + private _eol: string; + + private _lines: string[]; + private _lineStarts: PrefixSumComputer | null; + private _cachedTextValue: string | null; + + get uri(): URI { + return this._uri; + } + + get filename(): string { + return path.basename(this._uri.path); + } + + get languageId(): string { + return this._languageId; + } + + get version(): number { + return this._version; + } + + get lineCount(): number { + // console.debug(`lineCount(): ${this._lineCount}`); + return this._lineCount; + } + + get range(): Range { + const { end } = this.lineAt(this.lineCount - 1).range; + const range = new Range(0, 0, end.line, end.character); + // console.debug( + // `range(): (${range.start.line},${range.start.character}),(${range.end.line},${range.end.character})`, + // ); + return range; + } + + get eol(): EndOfLine { + return this._eol === "\n" ? "LF" : "CRLF"; + } + + constructor( + uri: URI, + languageId: string, + version: number, + eol: string, + lines: string[], + ) { + this._uri = uri; + this._languageId = languageId; + this._version = version; + this._eol = eol; + this._lines = lines; + + this._lineCount = lines.length; + this._lineStarts = null; + this._cachedTextValue = null; + } + + public update(lines: string[]) { + this._lines = lines; + + this._lineCount = lines.length; + this._lineStarts = null; + this._cachedTextValue = null; + } + + public lineAt(lineOrPosition: number | Position): TextLine { + let line: number | undefined; + if (lineOrPosition instanceof Position) { + line = lineOrPosition.line; + } else if (typeof lineOrPosition === "number") { + line = lineOrPosition; + } + // console.debug(`lineAt() line=${line}`); + + if (typeof line !== "number" || line < 0 || Math.floor(line) !== line) { + throw new Error("Illegal value for `line`"); + } + // The position is adjusted if it is outside range + line = Math.min(line, this._lines.length - 1); + + return new NeovimTextLineImpl( + line, + this._lines[line], + line === this._lines.length - 1, + ); + } + + public offsetAt(position: Position): number { + // console.debug( + // `offsetAt() position=(${position.line},${position.character})`, + // ); + position = this._validatePosition(position); + this._ensureLineStarts(); + // console.debug( + // `offsetAt() returning ${ + // this._lineStarts!.getPrefixSum(position.line - 1) + position.character + // }`, + // ); + return ( + this._lineStarts!.getPrefixSum(position.line - 1) + position.character + ); + } + + public positionAt(offset: number): Position { + // console.debug(`positionAt() offset=${offset}`); + offset = Math.floor(offset); + offset = Math.max(0, offset); + + this._ensureLineStarts(); + const out = this._lineStarts!.getIndexOf(offset); + + const lineLength = this._lines[out.index].length; + + // Ensure we return a valid position + return new Position(out.index, Math.min(out.remainder, lineLength)); + } + + public getText(range?: Range): string { + if (range === undefined) { + // console.debug(`getText(all)`); + if (this._cachedTextValue == null) { + this._cachedTextValue = this._lines.join(this._eol); + } + // if (this._lines.length > 10) { + // console.debug( + // `getText() returning multiple lines: '${this._lines.slice(0, 10).join(this._eol)}' \n[stripped...]}`, + // ); + // } else { + // console.debug( + // `getText() returning multiple lines: '${this._cachedTextValue}'`, + // ); + // } + return this._cachedTextValue; + } else { + // console.debug( + // `getText(range=(${range?.start.line},${range?.start.character}),(${range?.end.line},${range?.end.character}))`, + // ); + } + + range = this._validateRange(range); + + if (range.isEmpty) { + // console.debug(`getText() returning empty`); + return ""; + } + + if (range.isSingleLine) { + // console.debug( + // `getText() returning single line '${this._lines[ + // range.start.line + // ].substring(range.start.character, range.end.character)}'`, + // ); + return this._lines[range.start.line].substring( + range.start.character, + range.end.character, + ); + } + + const lineEnding = this._eol, + startLineIndex = range.start.line, + endLineIndex = range.end.line, + resultLines: string[] = []; + + resultLines.push( + this._lines[startLineIndex].substring(range.start.character), + ); + for (let i = startLineIndex + 1; i < endLineIndex; i++) { + resultLines.push(this._lines[i]); + } + resultLines.push( + this._lines[endLineIndex].substring(0, range.end.character), + ); + // if (resultLines.length > 10) { + // console.debug( + // `getText() returning multiple lines: '${resultLines.slice(0, 10).join(lineEnding)}' \n[stripped...]}`, + // ); + // } else { + // console.debug( + // `getText() returning multiple lines: '${resultLines.join(lineEnding)}'`, + // ); + // } + return resultLines.join(lineEnding); + } + + // ---- range math + + private _validateRange(range: Range): Range { + if (!(range instanceof Range)) { + throw new Error("Invalid argument"); + } + + const start = this._validatePosition(range.start); + const end = this._validatePosition(range.end); + + if (start === range.start && end === range.end) { + return range; + } + return new Range(start.line, start.character, end.line, end.character); + } + + private _validatePosition(position: Position): Position { + if (!(position instanceof Position)) { + throw new Error("Invalid argument"); + } + + if (this._lines.length === 0) { + return position.with(0, 0); + } + + let { line, character } = position; + let hasChanged = false; + + if (line < 0) { + line = 0; + character = 0; + hasChanged = true; + } else if (line >= this._lines.length) { + line = this._lines.length - 1; + character = this._lines[line].length; + hasChanged = true; + } else { + const maxCharacter = this._lines[line].length; + if (character < 0) { + character = 0; + hasChanged = true; + } else if (character > maxCharacter) { + character = maxCharacter; + hasChanged = true; + } + } + + if (!hasChanged) { + return position; + } + return new Position(line, character); + } + + private _ensureLineStarts(): void { + if (!this._lineStarts) { + const eolLength = this._eol.length; + const linesLength = this._lines.length; + const lineStartValues = new Uint32Array(linesLength); + for (let i = 0; i < linesLength; i++) { + lineStartValues[i] = this._lines[i].length + eolLength; + } + this._lineStarts = new PrefixSumComputer(lineStartValues); + } + } +} + +// ---- math helpers + +export function toUint32(v: number): number { + if (v < 0) { + return 0; + } + const maxUint32 = 4294967295; // 2^32 - 1 + if (v > maxUint32) { + return maxUint32; + } + return v | 0; +} + +export class PrefixSumComputer { + /** + * values[i] is the value at index i + */ + private values: Uint32Array; + + /** + * prefixSum[i] = SUM(heights[j]), 0 <= j <= i + */ + private prefixSum: Uint32Array; + + /** + * prefixSum[i], 0 <= i <= prefixSumValidIndex can be trusted + */ + private readonly prefixSumValidIndex: Int32Array; + + constructor(values: Uint32Array) { + this.values = values; + this.prefixSum = new Uint32Array(values.length); + this.prefixSumValidIndex = new Int32Array(1); + this.prefixSumValidIndex[0] = -1; + } + + public getCount(): number { + return this.values.length; + } + + public getTotalSum(): number { + if (this.values.length === 0) { + return 0; + } + return this._getPrefixSum(this.values.length - 1); + } + + /** + * Returns the sum of the first `index + 1` many items. + * @returns `SUM(0 <= j <= index, values[j])`. + */ + public getPrefixSum(index: number): number { + if (index < 0) { + return 0; + } + + index = toUint32(index); + return this._getPrefixSum(index); + } + + private _getPrefixSum(index: number): number { + if (index <= this.prefixSumValidIndex[0]) { + return this.prefixSum[index]; + } + + let startIndex = this.prefixSumValidIndex[0] + 1; + if (startIndex === 0) { + this.prefixSum[0] = this.values[0]; + startIndex++; + } + + if (index >= this.values.length) { + index = this.values.length - 1; + } + + for (let i = startIndex; i <= index; i++) { + this.prefixSum[i] = this.prefixSum[i - 1] + this.values[i]; + } + this.prefixSumValidIndex[0] = Math.max(this.prefixSumValidIndex[0], index); + return this.prefixSum[index]; + } + + public getIndexOf(sum: number): PrefixSumIndexOfResult { + sum = Math.floor(sum); + + // Compute all sums (to get a fully valid prefixSum) + this.getTotalSum(); + + let low = 0; + let high = this.values.length - 1; + let mid = 0; + let midStop = 0; + let midStart = 0; + + while (low <= high) { + mid = (low + (high - low) / 2) | 0; + + midStop = this.prefixSum[mid]; + midStart = midStop - this.values[mid]; + + if (sum < midStart) { + high = mid - 1; + } else if (sum >= midStop) { + low = mid + 1; + } else { + break; + } + } + + return new PrefixSumIndexOfResult(mid, sum - midStart); + } +} + +export class PrefixSumIndexOfResult { + _prefixSumIndexOfResultBrand: void = undefined; + + constructor( + public readonly index: number, + public readonly remainder: number, + ) { + this.index = index; + this.remainder = remainder; + } +} diff --git a/packages/neovim-common/src/ide/neovim/NeovimTextEditorImpl.ts b/packages/neovim-common/src/ide/neovim/NeovimTextEditorImpl.ts new file mode 100644 index 0000000000..41bf8a7bce --- /dev/null +++ b/packages/neovim-common/src/ide/neovim/NeovimTextEditorImpl.ts @@ -0,0 +1,197 @@ +import { + BreakpointDescriptor, + Edit, + EditableTextEditor, + OpenLinkOptions, + Range, + RevealLineAt, + Selection, + TextEditor, + TextEditorOptions, +} from "@cursorless/common"; +import type { NeovimClient, Window } from "neovim"; +import { bufferSetSelections } from "../../neovimApi"; +import { neovimClipboardCopy, neovimClipboardPaste } from "../../neovimHelpers"; +import neovimEdit from "./NeovimEdit"; +import { NeovimIDE } from "./NeovimIDE"; +import { NeovimTextDocumentImpl } from "./NeovimTextDocumentImpl"; + +export class NeovimTextEditorImpl implements EditableTextEditor { + private _document: NeovimTextDocumentImpl; + private _selections: Selection[]; + private _visibleRanges: Range[]; + + constructor( + public readonly id: string, + private client: NeovimClient, + private neovimIDE: NeovimIDE, + private window: Window, + doc: NeovimTextDocumentImpl, + visibleRanges: Range[], + selections: Selection[], + ) { + this._document = doc; + this._selections = selections; + this._visibleRanges = visibleRanges; + } + + get document(): NeovimTextDocumentImpl { + return this._document; + } + + public updateDocument( + visibleRanges: Range[], + selections: Selection[], + doc?: NeovimTextDocumentImpl, + lines?: string[], + ): NeovimTextDocumentImpl { + if (doc) { + this._document = doc; + } else if (lines) { + this._document.update(lines); + } else { + throw Error("updateDocument(): invalid arguments"); + } + this._selections = selections; + this._visibleRanges = visibleRanges; + return this._document; + } + + get selections(): Selection[] { + return this._selections as Selection[]; + // throw Error("get selections Not implemented"); + } + + async setSelections(selections: Selection[]): Promise { + // We assume setting it on the neovim buffer never fails + // as we cache the selections in the editor beforehand + this._selections = selections; + await bufferSetSelections(this.client, selections); + } + + get visibleRanges(): Range[] { + return this._visibleRanges; + } + + get options(): TextEditorOptions { + throw Error("get options Not implemented"); + } + + set options(options: TextEditorOptions) { + throw Error("set options Not implemented"); + } + + get isActive(): boolean { + return true; + } + + public isEqual(other: TextEditor): boolean { + return this.id === other.id; + } + + public async revealRange(_range: Range): Promise { + // throw Error("revealRange Not implemented"); + } + + public revealLine(_lineNumber: number, _at: RevealLineAt): Promise { + throw Error("revealLine Not implemented"); + } + + public async edit(edits: Edit[]): Promise { + //throw Error("edit Not implemented"); + return await neovimEdit(this.client, this.neovimIDE, this.window, edits); + } + + public focus(): Promise { + return Promise.resolve(); + // throw Error("focus Not implemented"); + } + + public editNewNotebookCellAbove(): Promise< + (selection: Selection) => Selection + > { + throw Error("editNewNotebookCellAbove Not implemented"); + } + + public editNewNotebookCellBelow(): Promise { + throw Error("editNewNotebookCellBelow Not implemented"); + } + + public openLink(_range: Range, _options?: OpenLinkOptions): Promise { + throw Error("openLink Not implemented"); + } + + public fold(_ranges?: Range[]): Promise { + throw Error("fold Not implemented"); + } + + public unfold(_ranges?: Range[]): Promise { + throw Error("unfold Not implemented"); + } + + public toggleBreakpoint( + _descriptors?: BreakpointDescriptor[], + ): Promise { + throw Error("toggleBreakpoint Not implemented"); + } + + public async toggleLineComment(_ranges?: Range[]): Promise { + throw Error("toggleLineComment Not implemented"); + } + + public async clipboardCopy(_ranges?: Range[]): Promise { + await neovimClipboardCopy(this.client, this.neovimIDE); + } + + public async clipboardPaste(_ranges?: Range[]): Promise { + await neovimClipboardPaste(this.client, this.neovimIDE); + } + + public async indentLine(_ranges?: Range[]): Promise { + throw Error("indentLine Not implemented"); + } + + public async outdentLine(_ranges?: Range[]): Promise { + throw Error("outdentLine Not implemented"); + } + + public async insertLineAfter(_ranges?: Range[]): Promise { + throw Error("insertLineAfter Not implemented"); + } + + public insertSnippet(_snippet: string, _ranges?: Range[]): Promise { + throw Error("insertSnippet Not implemented"); + } + + public async rename(_range?: Range): Promise { + throw Error("rename Not implemented"); + } + + public async showReferences(_range?: Range): Promise { + throw Error("showReferences Not implemented"); + } + + public async quickFix(_range?: Range): Promise { + throw Error("quickFix Not implemented"); + } + + public async revealDefinition(_range?: Range): Promise { + throw Error("revealDefinition Not implemented"); + } + + public async revealTypeDefinition(_range?: Range): Promise { + throw Error("revealTypeDefinition Not implemented"); + } + + public async showHover(_range?: Range): Promise { + throw Error("showHover Not implemented"); + } + + public async showDebugHover(_range?: Range): Promise { + throw Error("showDebugHover Not implemented"); + } + + public async extractVariable(_range?: Range): Promise { + throw Error("extractVariable Not implemented"); + } +} diff --git a/packages/neovim-common/src/ide/neovim/NeovimTextLineImpl.ts b/packages/neovim-common/src/ide/neovim/NeovimTextLineImpl.ts new file mode 100644 index 0000000000..1ffea9121a --- /dev/null +++ b/packages/neovim-common/src/ide/neovim/NeovimTextLineImpl.ts @@ -0,0 +1,67 @@ +import { Range, TextLine } from "@cursorless/common"; + +export default class NeovimTextLineImpl implements TextLine { + private readonly _lineNumber: number; + private readonly _text: string; + private readonly _isLastLine: boolean; + + constructor(lineNumber: number, text: string, isLastLine: boolean) { + // console.debug( + // `NeovimTextLineImpl(): lineNumber=${lineNumber}, text='${text}', isLastLine=${isLastLine}`, + // ); + this._lineNumber = lineNumber; + this._text = text; + this._isLastLine = isLastLine; + } + + get lineNumber(): number { + return this._lineNumber; + } + + get text(): string { + // console.debug(`NeovimTextLineImpl.text()='${this._text}'`); + return this._text; + } + + get range(): Range { + // console.debug( + // `NeovimTextLineImpl.range(): range=(${this._lineNumber}, 0), (${this._lineNumber}, ${this._text.length})`, + // ); + return new Range(this._lineNumber, 0, this._lineNumber, this._text.length); + } + + get rangeIncludingLineBreak(): Range { + if (this._isLastLine) { + // console.debug( + // `NeovimTextLineImpl.rangeIncludingLineBreak(): last line=(${this.range.start.line}, ${this.range.start.character}), (${this.range.end.line}, ${this.range.end.character})`, + // ); + return this.range; + } + // console.debug( + // `NeovimTextLineImpl.rangeIncludingLineBreak(): range=(${ + // this._lineNumber + // }, 0), ${this._lineNumber + 1}, 0})`, + // ); + return new Range(this._lineNumber, 0, this._lineNumber + 1, 0); + } + + get firstNonWhitespaceCharacterIndex(): number { + const index = /^(\s*)/.exec(this._text)![1].length; + // console.debug( + // `NeovimTextLineImpl.firstNonWhitespaceCharacterIndex=${index}`, + // ); + return index; + } + + get lastNonWhitespaceCharacterIndex(): number { + const index = this.text.trimEnd().length; + // console.debug( + // `NeovimTextLineImpl.lastNonWhitespaceCharacterIndex index=${index}`, + // ); + return index; + } + + get isEmptyOrWhitespace(): boolean { + return this.firstNonWhitespaceCharacterIndex === this._text.length; + } +} diff --git a/packages/neovim-common/src/index.ts b/packages/neovim-common/src/index.ts new file mode 100644 index 0000000000..94ef66ffd0 --- /dev/null +++ b/packages/neovim-common/src/index.ts @@ -0,0 +1,16 @@ +export * from "./getExtensionApi"; +export * from "./TestHelpers"; +export * from "./testUtil/openNewEditor"; +export * from "./runCommand"; +export * from "./neovimApi"; +export * from "./ide/neovim/NeovimCapabilities"; +export * from "./ide/neovim/NeovimClipboard"; +export * from "./ide/neovim/NeovimConfiguration"; +export * from "./ide/neovim/NeovimEdit"; +export * from "./ide/neovim/NeovimEvents"; +export * from "./ide/neovim/NeovimGlobalState"; +export * from "./ide/neovim/NeovimIDE"; +export * from "./ide/neovim/NeovimMessages"; +export * from "./ide/neovim/NeovimTextDocumentImpl"; +export * from "./ide/neovim/NeovimTextEditorImpl"; +export * from "./ide/neovim/NeovimTextLineImpl"; diff --git a/packages/neovim-common/src/neovimApi.ts b/packages/neovim-common/src/neovimApi.ts new file mode 100644 index 0000000000..54fcdc0a71 --- /dev/null +++ b/packages/neovim-common/src/neovimApi.ts @@ -0,0 +1,171 @@ +// Helper directly calling into Neovim apis, generally lua, exported by cursorless.nvim +import { Position, Range, Selection } from "@cursorless/common"; +import type { NeovimClient } from "neovim/lib/api/client"; +import type { Window } from "neovim/lib/api/Window"; + +/** + * Get the current "selections" in the window(editor) + * + * At the moment we only support one selection because vim only supports one cursor + * At the moment, we only support the current window, hence why the argument is not used + */ +export async function bufferGetSelections( + window: Window, + client: NeovimClient, +): Promise { + const luaCode = `return require("cursorless").buffer_get_selection()`; + // Note lines are indexed from 1, similarly to what is shown in neovim + // and columns are also indexed from 1 + const [startLine, startCol, endLine, endCol, reverse] = + (await client.executeLua(luaCode, [])) as [ + number, + number, + number, + number, + boolean, + ]; + // subtract 1 to the lines/columns to get the correct 0-based line/column numbers + let selections: Selection[]; + if (reverse === true) { + selections = [ + new Selection( + new Position(endLine - 1, endCol - 1), + new Position(startLine - 1, startCol - 1), + ), + ]; + } else { + selections = [ + new Selection( + new Position(startLine - 1, startCol - 1), + new Position(endLine - 1, endCol - 1), + ), + ]; + } + + console.debug( + `bufferGetSelections(): selections=(${selections[0].start.line}, ${selections[0].start.character}), (${selections[0].end.line}, ${selections[0].end.character}) neovim=(${startLine},${startCol}),(${endLine},${endCol}),reverse=${reverse}`, + ); + return selections; +} + +export async function bufferSetSelections( + // window: Window, + client: NeovimClient, + selections: Selection[], +) { + if (selections.length !== 1) { + throw new Error("bufferSetSelections() only supports one selection"); + } + + // cursorless has 0-based lines/columns, but neovim has 1-based lines and 0-based columns + // also, experience shows we need to subtract 1 from the end character to stop on it in visual mode (instead of after it) + // https://neovim.io/doc/user/api.html#nvim_win_set_cursor() + const luaCode = `return require("cursorless").select_range(${ + selections[0].start.line + 1 + }, ${selections[0].start.character}, ${selections[0].end.line + 1}, ${ + selections[0].end.character + })`; + console.debug( + `bufferSetSelections() selections=(${selections[0].start.line},${selections[0].start.character}),(${selections[0].end.line},${selections[0].end.character}) luaCode="${luaCode}"`, + ); + await client.executeLua(luaCode, []); + // console.debug(`bufferSetSelections() done`); +} + +/** + * Get the current "visible" ranges in the window(editor) (vertically). + * This accounts only for vertical scrolling, and not for horizontal scrolling. + * + * At the moment, we only support the current window, hence why the argument is not used + */ +export async function windowGetVisibleRanges( + window: Window, + client: NeovimClient, + lines: string[], +): Promise { + // Get the first and last visible lines of the current window + // Note they are indexed from 1, similarly to what is shown in neovim* + const luaCode = `return require("cursorless").window_get_visible_lines()`; + const [firstLine, lastLine] = (await client.executeLua(luaCode, [])) as [ + number, + number, + ]; + // subtract 1 to the lines to get the correct 0-based line numbers + const range = new Range( + new Position(firstLine - 1, 0), + // subtract -1 to the line.length to get the correct 0-based column number + new Position(lastLine - 1, lines[lastLine - 1].length - 1), + ); + console.debug( + `windowGetVisibleRanges(): range=(${range.start.line}, ${range.start.character}), (${range.end.line}, ${range.end.character})`, + ); + return [range]; +} + +export async function getCursorlessNvimPath( + client: NeovimClient, +): Promise { + const luaCode = `return require("cursorless.utils").cursorless_nvim_path()`; + const data = (await client.executeLua(luaCode, [])) as unknown as string; + return data as unknown as string; +} + +/** + * Save the data string into the operating system clipboard + * https://vimdoc.sourceforge.net/htmldoc/eval.html#setreg() + * https://stackoverflow.com/questions/11489428/how-can-i-make-vim-paste-from-and-copy-to-the-systems-clipboard?page=1&tab=scoredesc#tab-top + * https://stackoverflow.com/questions/30691466/what-is-difference-between-vims-clipboard-unnamed-and-unnamedplus-settings + */ +export async function setClipboard(data: string, client: NeovimClient) { + await client.callFunction("setreg", ["*", data]); +} + +/** + * Return the string from the operating system clipboard + * https://vimdoc.sourceforge.net/htmldoc/eval.html#getreg() + */ +export async function getClipboard(client: NeovimClient): Promise { + return await client.callFunction("getreg", ["*"]); +} + +/** + * Paste at current position from the operating system clipboard + */ +export async function pasteFromClipboard(client: NeovimClient) { + const luaCode = `return require("cursorless.utils").paste()`; + await client.executeLua(luaCode, []); +} + +/** + * Switch from terminal (t) mode to normal terminal (nt) mode + * @param client + */ +export async function modeSwitchNormalTerminal( + client: NeovimClient, +): Promise { + const luaCode = `return require("cursorless.utils").mode_switch_nt()`; + await client.executeLua(luaCode, []); +} + +/** + * Switch from normal terminal (nt) mode to terminal (t) mode + * @param client + */ +export async function modeSwitchTerminal(client: NeovimClient): Promise { + const luaCode = `return require("cursorless.utils").mode_switch_t()`; + await client.executeLua(luaCode, []); +} + +/** + * Show an error message to the user + * @param client + * @param message + * @see https://neovim.io/doc/user/lua.html#vim.notify() + */ +export async function showErrorMessage( + client: NeovimClient, + message: string, +): Promise { + const luaCode = `vim.notify("${message}")`; + await client.executeLua(luaCode, []); +} diff --git a/packages/neovim-common/src/neovimHelpers.ts b/packages/neovim-common/src/neovimHelpers.ts new file mode 100644 index 0000000000..ca9d23ca65 --- /dev/null +++ b/packages/neovim-common/src/neovimHelpers.ts @@ -0,0 +1,28 @@ +// Helper wrappers, generally around neovimApi.ts + +import { + bufferGetSelections, + pasteFromClipboard, + setClipboard, +} from "@cursorless/neovim-common"; +import { NeovimTextEditorImpl } from "./ide/neovim/NeovimTextEditorImpl"; +import type { NeovimClient } from "neovim"; +import { IDE } from "@cursorless/common"; + +export async function neovimClipboardCopy( + client: NeovimClient, + ide: IDE, +): Promise { + const editor = ide.activeTextEditor as NeovimTextEditorImpl; + const window = await client.window; + const selections = await bufferGetSelections(window, client); + const data = editor.document.getText(selections[0]); + await setClipboard(data, client); +} + +export async function neovimClipboardPaste( + client: NeovimClient, + _ide: IDE, +): Promise { + await pasteFromClipboard(client); +} diff --git a/packages/neovim-common/src/runCommand.ts b/packages/neovim-common/src/runCommand.ts new file mode 100644 index 0000000000..c25146c2e1 --- /dev/null +++ b/packages/neovim-common/src/runCommand.ts @@ -0,0 +1,15 @@ +import { + Command, + CommandResponse, + CURSORLESS_COMMAND_ID, +} from "@cursorless/common"; +import { getNeovimRegistry } from "@cursorless/neovim-registry"; + +export async function runCursorlessCommand( + command: Command, +): Promise { + return await getNeovimRegistry().executeCommand( + CURSORLESS_COMMAND_ID, + command, + ); +} diff --git a/packages/neovim-common/src/testUtil/openNewEditor.ts b/packages/neovim-common/src/testUtil/openNewEditor.ts new file mode 100644 index 0000000000..5f2b5da610 --- /dev/null +++ b/packages/neovim-common/src/testUtil/openNewEditor.ts @@ -0,0 +1,36 @@ +import { NeovimTextEditorImpl } from ".."; +import { NeovimTextDocumentImpl } from "../ide/neovim/NeovimTextDocumentImpl"; + +export interface NewEditorOptions { + languageId?: string; + openBeside?: boolean; +} + +export async function openNewEditor( + content: string, + _NewEditorOptions = {}, +): Promise { + throw new Error("openNewEditor() Not implemented"); +} + +export async function reuseEditor( + editor: NeovimTextDocumentImpl, // vscode.TextEditor, + content: string, + _language: string = "plaintext", +) { + throw new Error("reuseEditor() Not implemented"); +} + +/** + * Open a new notebook editor with the given cells + * @param cellContents A list of strings each of which will become the contents + * of a cell in the notebook + * @param _language The language id to use for all the cells in the notebook + * @returns notebook + */ +export async function openNewNotebookEditor( + cellContents: string[], + _language: string = "plaintext", +) { + throw new Error("openNewNotebookEditor() Not implemented"); +} diff --git a/packages/neovim-common/tsconfig.json b/packages/neovim-common/tsconfig.json new file mode 100644 index 0000000000..ef71cc5ee9 --- /dev/null +++ b/packages/neovim-common/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "out" + }, + "references": [ + { + "path": "../common" + }, + { + "path": "../neovim-registry" + }, + { + "path": "../node-common" + } + ], + "include": ["src/**/*.ts", "src/**/*.json", "../../typings/**/*.d.ts"] +} diff --git a/packages/neovim-registry/package.json b/packages/neovim-registry/package.json new file mode 100644 index 0000000000..9a79ea9d4c --- /dev/null +++ b/packages/neovim-registry/package.json @@ -0,0 +1,29 @@ +{ + "name": "@cursorless/neovim-registry", + "version": "1.0.0", + "description": "Registry to share apis and commands between packages for neovim", + "main": "./out/index.js", + "scripts": { + "clean": "rm -rf ./out tsconfig.tsbuildinfo ./dist ./build", + "compile:tsc": "tsc --build", + "compile:esbuild": "esbuild ./src/index.ts --sourcemap --format=esm --bundle --packages=external --outfile=./out/index.js", + "compile": "pnpm compile:tsc && pnpm compile:esbuild", + "watch:tsc": "pnpm compile:tsc --watch", + "watch:esbuild": "pnpm compile:esbuild --watch", + "watch": "pnpm run --filter @cursorless/neovim-registry --parallel '/^watch:.*/'" + }, + "keywords": [], + "author": "", + "license": "MIT", + "types": "./out/index.d.ts", + "exports": { + ".": { + "cursorless:bundler": "./src/index.ts", + "default": "./out/index.js" + } + }, + "devDependencies": { + "neovim": "5.1.0" + }, + "type": "module" +} diff --git a/packages/neovim-registry/src/NeovimRegistry.ts b/packages/neovim-registry/src/NeovimRegistry.ts new file mode 100644 index 0000000000..bf5fc5f916 --- /dev/null +++ b/packages/neovim-registry/src/NeovimRegistry.ts @@ -0,0 +1,39 @@ +import { EventEmitter } from "node:events"; + +export class NeovimRegistry { + private apis: Map; + private commands: Map; + private eventEmitter: EventEmitter; + + constructor() { + this.apis = new Map(); + this.commands = new Map(); + this.eventEmitter = new EventEmitter(); + } + + public registerExtensionApi(extensionId: string, api: any) { + this.apis.set(extensionId, api); + } + + public getExtensionApi(extensionId: string): any { + return this.apis.get(extensionId); + } + + public registerCommand(commandId: string, callback: any) { + this.commands.set(commandId, callback); + } + + public async executeCommand(commandId: string, ...rest: any[]): Promise { + return await this.commands.get(commandId)(...rest); + } + + public onEvent( + eventName: string | symbol, + listener: (...args: any[]) => void, + ): EventEmitter { + return this.eventEmitter.on(eventName, listener); + } + public emitEvent(eventName: string | symbol, ...args: any[]): boolean { + return this.eventEmitter.emit(eventName, ...args); + } +} diff --git a/packages/neovim-registry/src/index.ts b/packages/neovim-registry/src/index.ts new file mode 100644 index 0000000000..88affc282d --- /dev/null +++ b/packages/neovim-registry/src/index.ts @@ -0,0 +1,8 @@ +import { NeovimRegistry } from "./NeovimRegistry"; + +export function getNeovimRegistry(): NeovimRegistry { + if ((global as any)._neovimRegistry == null) { + (global as any)._neovimRegistry = new NeovimRegistry(); + } + return (global as any)._neovimRegistry; +} diff --git a/packages/neovim-registry/tsconfig.json b/packages/neovim-registry/tsconfig.json new file mode 100644 index 0000000000..cea565539b --- /dev/null +++ b/packages/neovim-registry/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "out" + }, + "references": [], + "include": ["src/**/*.ts", "src/**/*.json", "../../typings/**/*.d.ts"] +} diff --git a/packages/node-common/src/FileSystemRawTreeSitterQueryProvider.ts b/packages/node-common/src/FileSystemRawTreeSitterQueryProvider.ts index 50586795a6..10282923fb 100644 --- a/packages/node-common/src/FileSystemRawTreeSitterQueryProvider.ts +++ b/packages/node-common/src/FileSystemRawTreeSitterQueryProvider.ts @@ -19,8 +19,8 @@ export class FileSystemRawTreeSitterQueryProvider ide: IDE, private fileSystem: FileSystem, ) { - // Use the repo root as the root for development mode, so that we can - // we can make hot-reloading work for the queries + // Use the repo root as the root for development mode, so that we can make + // hot-reloading work for the queries this.queryDir = ide.runMode === "development" ? path.join(getCursorlessRepoRoot(), "queries") diff --git a/packages/node-common/src/nodeGetRunMode.ts b/packages/node-common/src/nodeGetRunMode.ts index 2a8f07e0ff..a97e6bc44a 100644 --- a/packages/node-common/src/nodeGetRunMode.ts +++ b/packages/node-common/src/nodeGetRunMode.ts @@ -4,6 +4,7 @@ import type { RunMode } from "@cursorless/common"; * Get the current run mode of the application as defined by the CURSORLESS_MODE * environment variable. * + * @see https://code.visualstudio.com/api/references/vscode-api#ExtensionMode * @returns The current run mode of the application */ export function nodeGetRunMode(): RunMode { diff --git a/packages/test-harness/README.md b/packages/test-harness/README.md new file mode 100644 index 0000000000..0b64f2fcb6 --- /dev/null +++ b/packages/test-harness/README.md @@ -0,0 +1,7 @@ +# Test harness + +This package contains the machinery used to test Cursorless, both in CI and locally, across various contexts (eg VSCode, Talon, Neovim, unit tests). Note that it does not actually contain any tests itself; those are either embedded in `.test.ts` files either next to the code they're testing or in standalone `*-e2e` packages. + +This package bundles tests into `.mjs` files and includes scripts used to run tests. + +Note that we currently have a hack where the `package.json` here appears as if it is only used for Neovim. That is because none of our other test runners require a `package.json`, so they are happy to ignore fields such as `main` and `types` that are required for Neovim to be able to import the test harness. See [#2564](https://github.com/cursorless-dev/cursorless/issues/2564) to track progress on a more elegant solution. diff --git a/packages/test-harness/package.json b/packages/test-harness/package.json index 516d50e072..5ed63d41cf 100644 --- a/packages/test-harness/package.json +++ b/packages/test-harness/package.json @@ -3,12 +3,14 @@ "version": "0.1.0", "description": "Contains scripts and runners for testing Cursorless", "private": true, - "main": "./out/index.js", + "main": "./out/runners/extensionTestsNeovim.cjs", "scripts": { - "test": "env CURSORLESS_MODE=test my-ts-node src/scripts/runTestsCI.ts", + "test": "env CURSORLESS_MODE=test my-ts-node src/scripts/runVscodeTestsCI.ts", + "test:neovim": "env CURSORLESS_MODE=test my-ts-node src/scripts/runNeovimTestsCI.ts", "build:base": "esbuild --sourcemap --conditions=cursorless:bundler --bundle --external:vscode --external:./reporters/parallel-buffered --external:./worker.js --format=cjs --platform=node", - "build": "pnpm run build:runner && pnpm run build:tests && pnpm run build:unit && pnpm run build:talon", - "build:runner": "pnpm run build:base ./src/runners/extensionTestsVscode.ts --outfile=dist/extensionTestsVscode.cjs", + "build": "pnpm run build:runner:vscode && pnpm run build:runner:neovim && pnpm run build:tests && pnpm run build:unit && pnpm run build:talon", + "build:runner:vscode": "pnpm run build:base ./src/runners/extensionTestsVscode.ts --outfile=dist/extensionTestsVscode.cjs", + "build:runner:neovim": "pnpm run build:base ./src/runners/extensionTestsNeovim.ts --outfile=out/runners/extensionTestsNeovim.cjs", "build:unit": "pnpm run build:base ./src/scripts/runUnitTestsOnly.ts --outfile=dist/runUnitTestsOnly.cjs", "build:talon": "pnpm run build:base ./src/scripts/runTalonTests.ts --outfile=dist/runTalonTests.cjs", "build:tests": "bash ./scripts/build-tests.sh", @@ -24,19 +26,23 @@ "dependencies": { "@cursorless/common": "workspace:*", "@cursorless/node-common": "workspace:*", - "glob": "^10.3.10" + "@cursorless/neovim-common": "workspace:*", + "@cursorless/neovim-registry": "workspace:*", + "glob": "^10.3.10", + "neovim": "5.1.0", + "tail": "2.2.6" }, "devDependencies": { "@types/glob": "^8.1.0", "@types/mocha": "^10.0.6", + "@types/tail": "2.2.3", "@vscode/test-electron": "^2.3.9", "mocha": "^10.3.0" }, - "types": "./out/index.d.ts", + "types": "./out/runners/extensionTestsNeovim.d.ts", "exports": { ".": { - "cursorless:bundler": "./src/index.ts", - "default": "./out/index.js" + "default": "./out/runners/extensionTestsNeovim.cjs" } }, "type": "module" diff --git a/packages/test-harness/src/config/init.lua b/packages/test-harness/src/config/init.lua new file mode 100644 index 0000000000..55f3608521 --- /dev/null +++ b/packages/test-harness/src/config/init.lua @@ -0,0 +1,8 @@ +local temp_dir = os.getenv("TEMP_DIR") +local repo_root = os.getenv("CURSORLESS_REPO_ROOT") + +vim.opt.runtimepath:append(temp_dir .. "/talon.nvim") +vim.opt.runtimepath:append(repo_root .. "/dist/cursorless.nvim") + +require("talon").setup() +require("cursorless").setup() diff --git a/packages/test-harness/src/launchNeovimAndRunTests.ts b/packages/test-harness/src/launchNeovimAndRunTests.ts new file mode 100644 index 0000000000..d5e9901143 --- /dev/null +++ b/packages/test-harness/src/launchNeovimAndRunTests.ts @@ -0,0 +1,193 @@ +import { getEnvironmentVariableStrict } from "@cursorless/common"; +import { getCursorlessRepoRoot } from "@cursorless/node-common"; +import * as cp from "child_process"; +import { copyFile, mkdirSync, readdirSync } from "fs"; +import process from "node:process"; +import { Tail } from "tail"; + +/** + * Launches neovim, instructing it to run the test runner + * specified in {@link extensionTestsPath}. + * @param extensionTestsPath The path to test runner, passed to + * `--extensionTestsPath` + * + * Current working & workspace directory: + * - Windows: D:\a\cursorless\cursorless + * - Linux: /home/runner/work/cursorless/cursorless + * - OS X: /Users/runner/work/cursorless/cursorless + */ +export async function launchNeovimAndRunTests() { + let code = 1; // failure + try { + const cli = getEnvironmentVariableStrict("NEOVIM_PATH"); + + let nvimFolder = ""; + const initLuaFile = `${getCursorlessRepoRoot()}/packages/test-harness/src/config/init.lua`; + if (process.platform === "win32") { + nvimFolder = "C:/Users/runneradmin/AppData/Local/nvim/"; + } else if (process.platform === "linux") { + nvimFolder = "/home/runner/.config/nvim/"; + } else if (process.platform === "darwin") { + nvimFolder = "/Users/runner/.config/nvim/"; + } else { + console.error(`Unsupported platform: ${process.platform}`); + process.exit(1); + } + + console.log(`cli: ${cli}`); + + mkdirSync(nvimFolder, { recursive: true }); + + copyFile(initLuaFile, `${nvimFolder}/init.lua`, (err: any) => { + if (err) { + console.error(err); + } + }); + console.log("init.lua copying done"); + + console.log("listing nvim/:"); + readdirSync(nvimFolder).forEach((file) => { + console.log(`\t${file}`); + }); + + const logName = `${getCursorlessRepoRoot()}/packages/cursorless-neovim/out/nvim_node.log`; + + // testing normal nvim startup + //https://stackoverflow.com/questions/3025615/is-there-a-vim-runtime-log + // if (process.platform === "darwin" || process.platform === "win32") { + if (process.platform === "win32") { + // const { status, signal, error } = cp.spawnSync(cli, [`-V9`], { + const { status, signal, error } = cp.spawnSync(cli, [`-V25`], { + encoding: "utf-8", + stdio: "inherit", + env: { + ...process.env, + // ["NVIM_NODE_HOST_DEBUG"]: "1", + ["NVIM_NODE_LOG_FILE"]: logName, + ["NVIM_NODE_LOG_LEVEL"]: "debug", + ["CURSORLESS_MODE"]: "test", + }, + }); + console.log(`status: ${status}`); + console.log(`signal: ${signal}`); + console.log(`error: ${error}`); + + console.log(`Exiting early`); + process.exit(0); + } + + // https://neovim.io/doc/user/starting.html#--headless + const subprocess = cp.spawn(cli, [`--headless`], { + env: { + ...process.env, + ["NVIM_NODE_LOG_FILE"]: logName, + ["NVIM_NODE_LOG_LEVEL"]: "info", // default for testing + ["CURSORLESS_MODE"]: "test", + }, + }); + console.log("nvim started done"); + + // do not wait for nvim to exit to avoid any blocking + subprocess.unref(); + + console.log(`pid: ${subprocess.pid}`); + + // Make sure the node log file exists + await delay(5000); + + console.log("listing cursorless-neovim/out/:"); + readdirSync( + `${getCursorlessRepoRoot()}/packages/cursorless-neovim/out/`, + ).forEach((file) => { + console.log(`\t${file}`); + }); + + await delay(10000); + + // read log file live and print to console + // https://stackoverflow.com/questions/26788504/using-node-js-to-read-a-live-file-line-by-line + let done = false; + let tailTest; + try { + tailTest = new Tail(logName, { + fromBeginning: true, + }); + } catch (error) { + console.log( + "Warning: A missing log file at nvim startup is typically the result of an invalid nvim config file", + ); + console.log(error); + code = 3; + process.exit(code); + } + tailTest.on("line", function (data: string) { + console.log(`neovim test: ${data}`); + if (data.includes("==== TESTS FINISHED:")) { + done = true; + console.log(`done: ${done}`); + const found = data.match(/.*==== TESTS FINISHED: code: (\d+).*/); + console.log(`found: ${found}`); + if (found != null) { + code = parseInt(found[1]); + console.log(`code: ${code}`); + } + } + }); + tailTest.on("error", function (error) { + console.log("neovim test: ERROR: ", error); + if (error.includes("==== TESTS FINISHED:")) { + done = true; + console.log(`done: ${done}`); + } + }); + console.log("tail neovim test started"); + + console.log("waiting for tests to finish ..."); + + let count = 0; + const stepSeconds = 10; + while (true) { + count += stepSeconds; + await delay(stepSeconds * 1000); + if (done) { + console.log("done here, exiting loop"); + break; + } + // exit if tests take more than 5 minutes + if (count > 5 * 60) { + console.log("timeout, exiting loop"); + break; + } + } + subprocess.kill("SIGTERM"); + console.log(`killed: ${subprocess.killed}`); + + // XXX - code to replace above code, needs more testing + // code from packages\cursorless-vscode\src\scripts\initLaunchSandbox.ts + // await new Promise((resolve, reject) => { + // subprocess.on("error", reject); + // subprocess.on("exit", (code) => { + // console.log(`exit: Process returned code ${code}`); + // if (code === 0) { + // resolve(); + // } else { + // reject(new Error(`Process returned code ${code}`)); + // } + // }); + // }); + console.log("tests finished"); + + tailTest.unwatch(); + } catch (err) { + console.error("Test run threw exception:"); + console.error(err); + code = 2; + } + console.log(`Returned code: ${code}`); + process.exit(code); +} + +// https://stackoverflow.com/questions/37764665/how-to-implement-sleep-function-in-typescript +function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/test-harness/src/launchVscodeAndRunTests.ts b/packages/test-harness/src/launchVscodeAndRunTests.ts index 4202f90ce5..facf5ee71f 100644 --- a/packages/test-harness/src/launchVscodeAndRunTests.ts +++ b/packages/test-harness/src/launchVscodeAndRunTests.ts @@ -30,7 +30,7 @@ export async function launchVscodeAndRunTests(extensionTestsPath: string) { const crashDir = getEnvironmentVariableStrict("VSCODE_CRASH_DIR"); const logsDir = getEnvironmentVariableStrict("VSCODE_LOGS_DIR"); const useLegacyVscode = - getEnvironmentVariableStrict("VSCODE_VERSION") === "legacy"; + getEnvironmentVariableStrict("APP_VERSION") === "legacy"; // NB: We include the exact version here instead of in `test.yml` so that // we don't have to update the branch protection rules every time we bump diff --git a/packages/test-harness/src/runAllTests.ts b/packages/test-harness/src/runAllTests.ts index 7a13494156..a7356e48b2 100644 --- a/packages/test-harness/src/runAllTests.ts +++ b/packages/test-harness/src/runAllTests.ts @@ -8,7 +8,7 @@ import { glob } from "glob"; * Type of test to run, eg unit, vscode, talon */ export enum TestType { - /** Unit tests can be run without VSCode or Talon */ + /** Unit tests can be run without VSCode or Talon or Neovim */ unit, /** VSCode tests must be run from VSCode context */ @@ -16,6 +16,9 @@ export enum TestType { /** Talon tests require a running Talon instance */ talon, + + /** Neovim tests must be run from Neovim context */ + neovim, } export function runAllTests(...types: TestType[]) { @@ -23,6 +26,10 @@ export function runAllTests(...types: TestType[]) { path.join(getCursorlessRepoRoot(), "packages"), (files) => files.filter((f) => { + if (f.endsWith("neovim.test.cjs")) { + return types.includes(TestType.neovim); + } + if (f.endsWith("vscode.test.cjs")) { return types.includes(TestType.vscode); } diff --git a/packages/test-harness/src/runners/extensionTestsNeovim.ts b/packages/test-harness/src/runners/extensionTestsNeovim.ts new file mode 100644 index 0000000000..7d0f6e105c --- /dev/null +++ b/packages/test-harness/src/runners/extensionTestsNeovim.ts @@ -0,0 +1,43 @@ +import { TestType, runAllTests } from "../runAllTests"; + +import type { NeovimClient, NvimPlugin } from "neovim"; + +/** + * Runs all extension tests. This function should only be called after attaching to the + * "node" process, such as when testing cursorless in neovim. + * We use this runner for both the local test launch config + * and the CI test runner action. + * @returns A promise that resolves when tests have finished running + */ +export async function run(plugin: NvimPlugin): Promise { + /** + * We need to pass the neovim client to the tests that are executed through mocha, + * so we add it to the global object. + */ + const client = plugin.nvim as NeovimClient; + (global as any).additionalParameters = { + client: client, + }; + let code = 0; + // NOTE: the parsing of the logs below is only done on CI in order to detect success/failure + try { + await runAllTests(TestType.neovim, TestType.unit); + console.log(`==== TESTS FINISHED: code: ${code}`); + } catch (error) { + console.log(`==== TESTS ERROR:`); + console.error(error); + code = 1; + console.log(`==== TESTS FINISHED: code: ${code}`); + } +} + +/** + * Extension entrypoint called by node-client on Neovim startup. + * - Register the functions that are exposed to Neovim. + * Note that these function need to start with a capital letter to be callable from Neovim. + */ +export default function entry(plugin: NvimPlugin) { + plugin.registerFunction("TestHarnessRun", () => run(plugin), { + sync: false, + }); +} diff --git a/packages/test-harness/src/scripts/runNeovimTestsCI.ts b/packages/test-harness/src/scripts/runNeovimTestsCI.ts new file mode 100644 index 0000000000..3d2bbb5359 --- /dev/null +++ b/packages/test-harness/src/scripts/runNeovimTestsCI.ts @@ -0,0 +1,11 @@ +/** + * This file can be run from node to run neovim tests in CI + */ + +import { launchNeovimAndRunTests } from "../launchNeovimAndRunTests"; + +(async () => { + // Note that we run all extension tests, including unit tests, in neovim, even though + // unit tests could be run separately. + await launchNeovimAndRunTests(); +})(); diff --git a/packages/test-harness/src/scripts/runTestsCI.ts b/packages/test-harness/src/scripts/runVscodeTestsCI.ts similarity index 89% rename from packages/test-harness/src/scripts/runTestsCI.ts rename to packages/test-harness/src/scripts/runVscodeTestsCI.ts index 020d090d8b..6382d16aea 100644 --- a/packages/test-harness/src/scripts/runTestsCI.ts +++ b/packages/test-harness/src/scripts/runVscodeTestsCI.ts @@ -1,5 +1,5 @@ /** - * This file can be run from node to run tests in CI + * This file can be run from node to run vscode tests in CI */ import { getCursorlessRepoRoot } from "@cursorless/node-common"; diff --git a/packages/test-harness/tsconfig.json b/packages/test-harness/tsconfig.json index 28f94c07f4..e49927123b 100644 --- a/packages/test-harness/tsconfig.json +++ b/packages/test-harness/tsconfig.json @@ -5,6 +5,12 @@ { "path": "../common" }, + { + "path": "../neovim-common" + }, + { + "path": "../neovim-registry" + }, { "path": "../node-common" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4d9fee174..6205fa1224 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -319,6 +319,83 @@ importers: specifier: ^17.0.1 version: 17.0.1 + packages/cursorless-neovim: + dependencies: + '@cursorless/common': + specifier: workspace:* + version: link:../common + '@cursorless/cursorless-engine': + specifier: workspace:* + version: link:../cursorless-engine + '@cursorless/neovim-common': + specifier: workspace:* + version: link:../neovim-common + '@cursorless/neovim-registry': + specifier: workspace:* + version: link:../neovim-registry + '@cursorless/node-common': + specifier: workspace:* + version: link:../node-common + '@cursorless/test-case-recorder': + specifier: workspace:* + version: link:../test-case-recorder + devDependencies: + '@types/chai': + specifier: ^4.3.14 + version: 4.3.14 + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 + '@types/lodash': + specifier: 4.17.0 + version: 4.17.0 + '@types/uuid': + specifier: ^9.0.8 + version: 9.0.8 + lodash: + specifier: ^4.17.21 + version: 4.17.21 + neovim: + specifier: 5.1.0 + version: 5.1.0 + vscode-uri: + specifier: ^3.0.8 + version: 3.0.8 + + packages/cursorless-neovim-e2e: + dependencies: + '@cursorless/common': + specifier: workspace:* + version: link:../common + '@cursorless/neovim-common': + specifier: workspace:* + version: link:../neovim-common + '@cursorless/neovim-registry': + specifier: workspace:* + version: link:../neovim-registry + '@cursorless/node-common': + specifier: workspace:* + version: link:../node-common + '@cursorless/test-case-recorder': + specifier: workspace:* + version: link:../test-case-recorder + devDependencies: + '@types/chai': + specifier: ^4.3.14 + version: 4.3.14 + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 + '@types/lodash': + specifier: 4.17.0 + version: 4.17.0 + '@types/sinon': + specifier: ^17.0.3 + version: 17.0.3 + neovim: + specifier: 5.1.0 + version: 5.1.0 + packages/cursorless-org: dependencies: '@cursorless/cheatsheet': @@ -677,6 +754,42 @@ importers: specifier: ^0.20.2 version: 0.20.2 + packages/neovim-common: + dependencies: + '@cursorless/common': + specifier: workspace:* + version: link:../common + '@cursorless/neovim-registry': + specifier: workspace:* + version: link:../neovim-registry + '@cursorless/node-common': + specifier: workspace:* + version: link:../node-common + '@types/lodash': + specifier: 4.17.0 + version: 4.17.0 + '@types/uuid': + specifier: ^9.0.8 + version: 9.0.8 + '@types/vscode': + specifier: 1.75.1 + version: 1.75.1 + lodash: + specifier: ^4.17.21 + version: 4.17.21 + neovim: + specifier: 5.1.0 + version: 5.1.0 + vscode-uri: + specifier: ^3.0.8 + version: 3.0.8 + + packages/neovim-registry: + devDependencies: + neovim: + specifier: 5.1.0 + version: 5.1.0 + packages/node-common: dependencies: '@cursorless/common': @@ -744,12 +857,24 @@ importers: '@cursorless/common': specifier: workspace:* version: link:../common + '@cursorless/neovim-common': + specifier: workspace:* + version: link:../neovim-common + '@cursorless/neovim-registry': + specifier: workspace:* + version: link:../neovim-registry '@cursorless/node-common': specifier: workspace:* version: link:../node-common glob: specifier: ^10.3.10 version: 10.3.10 + neovim: + specifier: 5.1.0 + version: 5.1.0 + tail: + specifier: 2.2.6 + version: 2.2.6 devDependencies: '@types/glob': specifier: ^8.1.0 @@ -757,6 +882,9 @@ importers: '@types/mocha': specifier: ^10.0.6 version: 10.0.6 + '@types/tail': + specifier: 2.2.3 + version: 2.2.3 '@vscode/test-electron': specifier: ^2.3.9 version: 2.3.9 @@ -1501,10 +1629,17 @@ packages: resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} + '@colors/colors@1.6.0': + resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} + engines: {node: '>=0.1.90'} + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@dabh/diagnostics@2.0.3': + resolution: {integrity: sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==} + '@discoveryjs/json-ext@0.5.7': resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} engines: {node: '>=10.0.0'} @@ -2034,6 +2169,10 @@ packages: '@types/react': '>=16' react: '>=16' + '@msgpack/msgpack@2.8.0': + resolution: {integrity: sha512-h9u4u/jiIRKbq25PM+zymTyW6bhTzELvOoUd+AvYriWOAKpLGnIamaET3pnHYoI5iYphAHBI4ayx0MehR+VVPQ==} + engines: {node: '>= 10'} + '@next/env@14.1.4': resolution: {integrity: sha512-e7X7bbn3Z6DWnDi75UWn+REgAbLEqxI8Tq2pkFOFAMpWAWApz/YCUhtWMWn410h8Q2fYiYL7Yg5OlxMOCfFjJQ==} @@ -3025,12 +3164,18 @@ packages: '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@types/tail@2.2.3': + resolution: {integrity: sha512-Hnf352egOlDR4nVTaGX0t/kmTNXHMdovF2C7PVDFtHTHJPFmIspOI1b86vEOxU7SfCq/dADS7ptbqgG/WGGxnA==} + '@types/tinycolor2@1.4.6': resolution: {integrity: sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==} '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/triple-beam@1.3.5': + resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} + '@types/unist@2.0.10': resolution: {integrity: sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==} @@ -3978,10 +4123,16 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + color-support@1.1.3: resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} hasBin: true + color@3.2.1: + resolution: {integrity: sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==} + colord@2.9.3: resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} @@ -3992,6 +4143,9 @@ packages: resolution: {integrity: sha512-pFGrxThWcWQ2MsAz6RtgeWe4NK2kUE1WfsrvvlctdII745EW9I0yflqhe7++M5LEc7bV2c/9/5zc8sFcpL0Drw==} engines: {node: '>=0.1.90'} + colorspace@1.1.4: + resolution: {integrity: sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==} + combine-promises@1.2.0: resolution: {integrity: sha512-VcQB1ziGD0NXrhKxiwyNbCDmRzs/OShMs2GqW2DlU2A/Sd0nQxE1oWDAE5O0ygSx5mgQOn9eIFh7yKPgFRVkPQ==} engines: {node: '>=10'} @@ -4634,6 +4788,9 @@ packages: emoticon@4.0.1: resolution: {integrity: sha512-dqx7eA9YaqyvYtUhJwT4rC1HIp82j5ybS1/vQ42ur+jBe17dJMwZE4+gvL1XadSFfxaPFFGt3Xsw+Y8akThDlw==} + enabled@2.0.0: + resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} + encodeurl@1.0.2: resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} engines: {node: '>= 0.8'} @@ -5056,6 +5213,9 @@ packages: fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + fecha@4.2.3: + resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} + feed@4.2.2: resolution: {integrity: sha512-u5/sxGfiMfZNtJ3OvQpXcvotFpYkL0n9u9mM2vkui2nGo8b4wvDkJ8gAkYqbA8QpGyFCv3RK0Z+Iv+9veCS9bQ==} engines: {node: '>=0.4.0'} @@ -5144,6 +5304,9 @@ packages: flatted@3.3.1: resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} + fn.name@1.1.0: + resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} + follow-redirects@1.15.6: resolution: {integrity: sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==} engines: {node: '>=4.0'} @@ -5748,6 +5911,9 @@ packages: is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + is-arrayish@0.3.2: + resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==} + is-async-function@2.0.0: resolution: {integrity: sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==} engines: {node: '>= 0.4'} @@ -6320,6 +6486,9 @@ packages: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} + kuler@2.0.0: + resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} + language-subtag-registry@0.3.22: resolution: {integrity: sha512-tN0MCzyWnoz/4nHS6uxdlFWoUZT7ABptwKPQ52Ea7URk6vll88bWBVhodtnlfEuCcKWNGoc+uGbw1cwa9IKh/w==} @@ -6424,6 +6593,10 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} + logform@2.6.1: + resolution: {integrity: sha512-CdaO738xRapbKIMVn2m4F6KTj4j7ooJ8POVnebSgKo3KBz5axNXRAL7ZdRjIV6NOr2Uf4vjtRkxrFETOioCqSA==} + engines: {node: '>= 12.0.0'} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -6945,6 +7118,11 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + neovim@5.1.0: + resolution: {integrity: sha512-9eTxqknziWkN8CBOx1SKdK+0Dfp1NHKHKyJaeOYu+x6qjaV9z3hB211wKhLaFGtyYmGZxVaIe1aLtvuTHmuZTA==} + engines: {node: '>=10'} + hasBin: true + nerf-dart@1.0.0: resolution: {integrity: sha512-EZSPZB70jiVsivaBLYDCyntd5eH8NTSMOn3rB+HxwdmKThGELLdYv8qVIMWvZEFy9w8ZZpW9h9OB32l1rGtj7g==} @@ -7186,6 +7364,9 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + one-time@1.0.0: + resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} + onetime@5.1.2: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} @@ -8367,6 +8548,10 @@ packages: resolution: {integrity: sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==} engines: {node: '>= 0.4'} + safe-stable-stringify@2.4.3: + resolution: {integrity: sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==} + engines: {node: '>=10'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -8523,6 +8708,9 @@ packages: engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} hasBin: true + simple-swizzle@0.2.2: + resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + sinon@17.0.1: resolution: {integrity: sha512-wmwE19Lie0MLT+ZYNpDymasPHUKTaZHUH/pKEubRXIzySv9Atnlw+BUMGCzWgV7b7wO+Hw6f1TEOr0IUnmU8/g==} @@ -8668,6 +8856,9 @@ packages: stack-generator@2.0.10: resolution: {integrity: sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==} + stack-trace@0.0.10: + resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} + stack-utils@2.0.6: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} @@ -8877,6 +9068,10 @@ packages: engines: {node: '>=16'} hasBin: true + tail@2.2.6: + resolution: {integrity: sha512-IQ6G4wK/t8VBauYiGPLx+d3fA5XjSVagjWV5SIYzvEvglbQjwEcukeYI68JOPpdydjxhZ9sIgzRlSmwSpphHyw==} + engines: {node: '>= 6.0.0'} + tailwindcss@3.4.1: resolution: {integrity: sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==} engines: {node: '>=14.0.0'} @@ -8919,6 +9114,9 @@ packages: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} + text-hex@1.0.0: + resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} + text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} @@ -9009,6 +9207,10 @@ packages: resolution: {integrity: sha512-jRKj0n0jXWo6kh62nA5TEh3+4igKDXLvzBJcPpiizP7oOolUrYIxmVBG9TOtHYFHoddUk6YvAkGeGoSVTXfQXQ==} engines: {node: '>=12'} + triple-beam@1.4.1: + resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} + engines: {node: '>= 14.0.0'} + trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} @@ -9535,6 +9737,14 @@ packages: wildcard@2.0.1: resolution: {integrity: sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==} + winston-transport@4.7.1: + resolution: {integrity: sha512-wQCXXVgfv/wUPOfb2x0ruxzwkcZfxcktz6JIMUaPLmcNhO4bZTwA/WtDWK74xV3F2dKu8YadrFv0qhwYjVEwhA==} + engines: {node: '>= 12.0.0'} + + winston@3.11.0: + resolution: {integrity: sha512-L3yR6/MzZAOl0DsysUXHVjOwv8mKZ71TrA/41EIduGpOOV5LQVodqN+QdQ6BS6PJ/RdIshZhq84P/fStEZkk7g==} + engines: {node: '>= 12.0.0'} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -10587,10 +10797,18 @@ snapshots: '@colors/colors@1.5.0': optional: true + '@colors/colors@1.6.0': {} + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@dabh/diagnostics@2.0.3': + dependencies: + colorspace: 1.1.4 + enabled: 2.0.0 + kuler: 2.0.0 + '@discoveryjs/json-ext@0.5.7': {} '@docsearch/css@3.6.0': {} @@ -11702,6 +11920,8 @@ snapshots: '@types/react': 18.3.3 react: 18.2.0 + '@msgpack/msgpack@2.8.0': {} + '@next/env@14.1.4': {} '@next/eslint-plugin-next@14.1.4': @@ -12991,10 +13211,14 @@ snapshots: '@types/stack-utils@2.0.3': {} + '@types/tail@2.2.3': {} + '@types/tinycolor2@1.4.6': {} '@types/tough-cookie@4.0.5': {} + '@types/triple-beam@1.3.5': {} + '@types/unist@2.0.10': {} '@types/unist@3.0.2': {} @@ -14163,14 +14387,29 @@ snapshots: color-name@1.1.4: {} + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.2 + color-support@1.1.3: {} + color@3.2.1: + dependencies: + color-convert: 1.9.3 + color-string: 1.9.1 + colord@2.9.3: {} colorette@2.0.20: {} colors@1.0.3: {} + colorspace@1.1.4: + dependencies: + color: 3.2.1 + text-hex: 1.0.0 + combine-promises@1.2.0: {} combined-stream@1.0.8: @@ -14805,6 +15044,8 @@ snapshots: emoticon@4.0.1: {} + enabled@2.0.0: {} + encodeurl@1.0.2: {} encoding@0.1.13: @@ -15546,6 +15787,8 @@ snapshots: dependencies: bser: 2.1.1 + fecha@4.2.3: {} + feed@4.2.2: dependencies: xml-js: 1.6.11 @@ -15642,6 +15885,8 @@ snapshots: flatted@3.3.1: {} + fn.name@1.1.0: {} + follow-redirects@1.15.6: {} for-each@0.3.3: @@ -16387,6 +16632,8 @@ snapshots: is-arrayish@0.2.1: {} + is-arrayish@0.3.2: {} + is-async-function@2.0.0: dependencies: has-tostringtag: 1.0.2 @@ -17107,6 +17354,8 @@ snapshots: kleur@3.0.3: {} + kuler@2.0.0: {} + language-subtag-registry@0.3.22: {} language-tags@1.0.9: @@ -17203,6 +17452,15 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 + logform@2.6.1: + dependencies: + '@colors/colors': 1.6.0 + '@types/triple-beam': 1.3.5 + fecha: 4.2.3 + ms: 2.1.3 + safe-stable-stringify: 2.4.3 + triple-beam: 1.4.1 + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -18096,6 +18354,11 @@ snapshots: neo-async@2.6.2: {} + neovim@5.1.0: + dependencies: + '@msgpack/msgpack': 2.8.0 + winston: 3.11.0 + nerf-dart@1.0.0: {} next@14.1.4(@babel/core@7.24.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0): @@ -18412,6 +18675,10 @@ snapshots: dependencies: wrappy: 1.0.2 + one-time@1.0.0: + dependencies: + fn.name: 1.1.0 + onetime@5.1.2: dependencies: mimic-fn: 2.1.0 @@ -19662,6 +19929,8 @@ snapshots: es-errors: 1.3.0 is-regex: 1.1.4 + safe-stable-stringify@2.4.3: {} + safer-buffer@2.1.2: {} sax@1.3.0: {} @@ -19854,6 +20123,10 @@ snapshots: transitivePeerDependencies: - supports-color + simple-swizzle@0.2.2: + dependencies: + is-arrayish: 0.3.2 + sinon@17.0.1: dependencies: '@sinonjs/commons': 3.0.1 @@ -20023,6 +20296,8 @@ snapshots: dependencies: stackframe: 1.3.4 + stack-trace@0.0.10: {} + stack-utils@2.0.6: dependencies: escape-string-regexp: 2.0.0 @@ -20268,6 +20543,8 @@ snapshots: transitivePeerDependencies: - typescript + tail@2.2.6: {} + tailwindcss@3.4.1(ts-node@10.9.2(@types/node@18.18.2)(typescript@5.5.3)): dependencies: '@alloc/quick-lru': 5.2.0 @@ -20354,6 +20631,8 @@ snapshots: glob: 7.2.3 minimatch: 3.1.2 + text-hex@1.0.0: {} + text-table@0.2.0: {} textextensions@5.16.0: {} @@ -20425,6 +20704,8 @@ snapshots: trim-newlines@4.1.1: {} + triple-beam@1.4.1: {} + trough@2.2.0: {} ts-api-utils@1.3.0(typescript@5.5.3): @@ -21159,6 +21440,26 @@ snapshots: wildcard@2.0.1: {} + winston-transport@4.7.1: + dependencies: + logform: 2.6.1 + readable-stream: 3.6.2 + triple-beam: 1.4.1 + + winston@3.11.0: + dependencies: + '@colors/colors': 1.6.0 + '@dabh/diagnostics': 2.0.3 + async: 3.2.5 + is-stream: 2.0.1 + logform: 2.6.1 + one-time: 1.0.0 + readable-stream: 3.6.2 + safe-stable-stringify: 2.4.3 + stack-trace: 0.0.10 + triple-beam: 1.4.1 + winston-transport: 4.7.1 + word-wrap@1.2.5: {} workerpool@6.2.1: {} diff --git a/scripts/deploy-cursorless-nvim.sh b/scripts/deploy-cursorless-nvim.sh new file mode 100755 index 0000000000..5b7100065f --- /dev/null +++ b/scripts/deploy-cursorless-nvim.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# This script is used to push to the cursorless.nvim github production repo +set -euo pipefail + +# Clone current cursorless.nvim main +mkdir -p dist && cd dist +git clone 'https://github.com/hands-free-vim/cursorless.nvim.git' cursorless.nvim-remote +cd - + +out_dir=dist/cursorless.nvim-remote + +# Delete the old files +cd "$out_dir" +git rm -r '*' +cd - + +# copy static files +cp -r cursorless.nvim/* "$out_dir/" + +# copy the built .js file +mkdir -p "$out_dir/node/cursorless-neovim/out" +cp packages/cursorless-neovim/package.json "$out_dir/node/cursorless-neovim/" +cp packages/cursorless-neovim/out/index.cjs "$out_dir/node/cursorless-neovim/out/" + +# Extract commit message and body +commit_message="$(git log -1 --pretty=format:"%s" HEAD)" +commit_body="$(git log -1 --pretty=format:"%b" HEAD)" +author_name=$(git log -1 --pretty=format:"%an" HEAD) +author_email=$(git log -1 --pretty=format:"%ae" HEAD) +author_date=$(git log -1 --pretty=format:"%ad" --date=iso-strict HEAD) + +# Push to cursorless.nvim +cd "$out_dir" + +rm -rf test/ .busted +git add . +GIT_AUTHOR_NAME="$author_name" GIT_AUTHOR_EMAIL="$author_email" GIT_AUTHOR_DATE="$author_date" \ + git commit -m "$commit_message" -m "$commit_body" || true +git push diff --git a/scripts/deploy-cursorless-talon.sh b/scripts/deploy-cursorless-talon.sh old mode 100644 new mode 100755 index 26b2ff97b9..b9429910b4 --- a/scripts/deploy-cursorless-talon.sh +++ b/scripts/deploy-cursorless-talon.sh @@ -1,3 +1,4 @@ +#!/usr/bin/env bash # This script is used to push the cursorless-talon subtree to the cursorless # talon repo. We proceed by first performing a git subtree split and then # cherrypicking any new commits onto the cursorless-talon main branch diff --git a/scripts/install-neovim-dependencies.sh b/scripts/install-neovim-dependencies.sh new file mode 100755 index 0000000000..44c0293181 --- /dev/null +++ b/scripts/install-neovim-dependencies.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +npm install -g neovim@5.1.0 + +git clone https://github.com/hands-free-vim/talon.nvim "${TEMP_DIR}/talon.nvim" diff --git a/tsconfig.json b/tsconfig.json index 4e8a51b5e1..0e23d75450 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,12 @@ { "path": "./packages/cursorless-engine" }, + { + "path": "./packages/cursorless-neovim" + }, + { + "path": "./packages/cursorless-neovim-e2e" + }, { "path": "./packages/cursorless-org" }, @@ -38,6 +44,12 @@ { "path": "./packages/meta-updater" }, + { + "path": "./packages/neovim-common" + }, + { + "path": "./packages/neovim-registry" + }, { "path": "./packages/node-common" },