Rewrite Dance for v0.5.0.

This commit is contained in:
Grégoire Geis 2021-05-04 22:09:29 +02:00
parent c6ea85dbfc
commit 2d38438e15
177 changed files with 38404 additions and 16980 deletions

View File

@ -16,6 +16,12 @@ module.exports = {
"max-len": "off",
},
},
{
files: ["test/suite/commands/*.ts"],
rules: {
"no-useless-escape": "off",
},
}
],
rules: {
"indent": ["error", 2, {
@ -25,6 +31,7 @@ module.exports = {
"VariableDeclarator": "first",
"flatTernaryExpressions": true,
"offsetTernaryExpressions": true,
"ignoredNodes": ["TemplateLiteral *"],
}],
"curly": ["error", "all"],
"dot-location": ["error", "property"],
@ -41,7 +48,7 @@ module.exports = {
{
code: 100,
comments: 80,
ignorePattern: "^ *(\\*|//) ([sS]ee )?http\\S+\\)?.?$|^ *// =+( [^=]+ =+)?$",
ignorePattern: "^ *(\\*|//) ([sS]ee )?http\\S+\\)?.?$|^ *// =+( [^=]+ =+)?$|\|$",
},
],
"multiline-ternary": ["error", "always-multiline"],
@ -50,21 +57,19 @@ module.exports = {
"no-unexpected-multiline": "error",
"no-unneeded-ternary": "error",
"object-curly-spacing": ["error", "always"],
"operator-linebreak": ["error", "before"],
"operator-linebreak": ["error", "before", { overrides: { "=": "after" } }],
"object-shorthand": "error",
"quotes": ["error", "double", { avoidEscape: true, allowTemplateLiterals: true }],
"semi": ["error", "always"],
"sort-imports": [
"error",
{
ignoreCase: true,
ignoreDeclarationSort: true,
memberSyntaxSortOrder: ["none", "all", "single", "multiple"],
},
],
"space-before-function-paren": [
"error",
{ anonymous: "always", named: "never", asyncArrow: "always" },
],
"space-before-function-paren": "off",
"space-before-blocks": "error",
"space-infix-ops": "error",
"unicode-bom": "error",
@ -73,5 +78,9 @@ module.exports = {
"no-case-declarations": "off",
"no-cond-assign": "off",
"@typescript-eslint/explicit-member-accessibility": ["error"],
"@typescript-eslint/space-before-function-paren": [
"error",
{ anonymous: "always", named: "never", asyncArrow: "always" },
],
},
};

View File

@ -17,3 +17,14 @@ jobs:
- name: Check formatting and lints
run: yarn run check
- name: Ensure auto-generated files are up-to-date
run: yarn run ts-node ./meta.ts --ensure-up-to-date --check
- name: Check tests
run: yarn run test
- uses: butlerlogic/action-autotag@ade8d2e19bfcd1e6a91272e2849b4bf4c37a67f1
with:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
tag_prefix: v

44
.vscode/launch.json vendored
View File

@ -2,25 +2,55 @@
"version": "0.2.0",
"configurations": [
{
"name": "Run Extension",
"name": "Launch extension",
"type": "extensionHost",
"request": "launch",
"runtimeExecutable": "${execPath}",
"args": ["--extensionDevelopmentPath=${workspaceFolder}"],
"outFiles": ["${workspaceFolder}/out/**/*.js"],
"preLaunchTask": "npm: watch"
},
{
"name": "Run Extension Tests",
"name": "Run all tests",
"type": "extensionHost",
"request": "launch",
"runtimeExecutable": "${execPath}",
"args": [
"--disable-extensions",
"--extensionDevelopmentPath=${workspaceFolder}",
"--extensionTestsPath=${workspaceFolder}/out/test/suite"
"--extensionTestsPath=${workspaceFolder}/out/test/suite",
],
"outFiles": ["${workspaceFolder}/out/**/*.js"],
"preLaunchTask": "npm: watch"
}
]
},
{
"name": "Run tests in this file",
"type": "extensionHost",
"request": "launch",
"runtimeExecutable": "${execPath}",
"args": [
"--disable-extensions",
"--extensionDevelopmentPath=${workspaceFolder}",
"--extensionTestsPath=${workspaceFolder}/out/test/suite",
],
"outFiles": ["${workspaceFolder}/out/**/*.js"],
"env": {
"CURRENT_FILE": "${relativeFile}",
},
},
{
"name": "Run test on this line",
"type": "extensionHost",
"request": "launch",
"runtimeExecutable": "${execPath}",
"args": [
"--disable-extensions",
"--extensionDevelopmentPath=${workspaceFolder}",
"--extensionTestsPath=${workspaceFolder}/out/test/suite",
],
"outFiles": ["${workspaceFolder}/out/**/*.js"],
"env": {
"CURRENT_FILE": "${relativeFile}",
"CURRENT_LINE": "${lineNumber}",
},
},
],
}

31
.vscode/settings.json vendored
View File

@ -1,12 +1,31 @@
{
"files.associations": {
"test/suite/commands/*": "text"
},
"search.exclude": {
"out": true
},
"[typescript]": {
"editor.rulers": [80, 100]
"editor.rulers": [80, 100],
"typescript.tsdk": "node_modules/typescript/lib",
// Disable italic on doc comments since they contain a lot of Markdown:
"editor.tokenColorCustomizations": {
"textMateRules": [
{
"scope": "comment.block.documentation",
"settings": {
"fontStyle": "",
},
},
],
},
"dance.menus": {
"dancedev": {
"items": {
"b": {
"text": "toggle character selection behavior",
"command": "dance.dev.setSelectionBehavior",
"args": [{ "mode": "normal" }],
},
},
},
},
"typescript.tsdk": "node_modules/typescript/lib"
}

25
.vscode/tasks.json vendored
View File

@ -1,9 +1,11 @@
{
"version": "2.0.0",
"tasks": [
// These tasks run automatically when loading Dance in VS Code. Make sure to
// enable them (Command palette > Manage automatic tasks in folder > Allow).
{
"type": "npm",
"script": "watch",
"script": "compile:watch",
"problemMatcher": "$tsc-watch",
"isBackground": true,
"presentation": {
@ -12,7 +14,22 @@
"group": {
"kind": "build",
"isDefault": true
}
}
]
},
"runOptions": {
"runOn": "folderOpen",
},
},
{
"type": "npm",
"script": "generate:watch",
"problemMatcher": [],
"isBackground": true,
"presentation": {
"reveal": "never"
},
"runOptions": {
"runOn": "folderOpen",
},
},
],
}

View File

@ -9,3 +9,4 @@ test/**
**/*.map
**/*.ts
**/*.lock
out/src/build.js

View File

@ -1,4 +1,4 @@
Copyright 2020 Grégoire Geis
Copyright 2020-2021 Grégoire Geis
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.

364
README.md
View File

@ -2,12 +2,30 @@
[Kakoune]-inspired key bindings for [Visual Studio Code][vsc].
## Important note
The next release of Dance (available in this branch) is a complete rewrite from
the previous release. It adds many features (to list a few, custom modes, better
commands, and a scripting API) and has many QOL improvements (better status bar
buttons, better [character mode](#selection-behaviors), better history, support
for multiple editors for one document, more tests, and better internal
architecture).
While this will improve the quality of Dance over time, in the short term this
will make it much less stable due to bugs. If you encounter a bug, please file
an issue (or directly submit a PR) containing [test cases](./test) for the
command.
Thanks for bearing with me!
## Huh?
Dance provides [Kakoune]-inspired commands and key bindings for [Visual Studio Code][vsc].
Dance provides [Kakoune]-inspired commands and key bindings for
[Visual Studio Code][vsc], as well as support for custom modes and scripting.
These key bindings are (mostly) compatible with [Kakoune]'s, but are meant to be an addition
to [Visual Studio Code][vsc], rather than an emulation layer on top of it.
Added key bindings are (mostly) compatible with [Kakoune]'s, but are meant to be
an addition to [Visual Studio Code][vsc], rather than an emulation layer on top
of it.
#### Why [VS Code][vsc], and not [Kakoune] directly?
@ -17,250 +35,174 @@ to [Visual Studio Code][vsc], rather than an emulation layer on top of it.
#### Why [Kakoune]'s key bindings, and not [Vim]'s?
- Whether you prefer Vim, Emacs or Kakoune key bindings is a matter of preference. I, for one,
prefer [Kakoune's](https://github.com/mawww/kakoune/blob/master/doc/pages/keys.asciidoc).
- Whether you prefer Vim, Emacs or Kakoune key bindings is a matter of
preference. I, for one, prefer
[Kakoune's](https://github.com/mawww/kakoune/blob/master/doc/pages/keys.asciidoc).
- Vim's key bindings are [already available to VS Code users][vscodevim].
#### Why is it merely 'inspired' by [Kakoune]?
- Unlike [VSCodeVim] which attempts to emulate Vim, Dance's only goal is to provide
VS Code-native [commands][vsccommands] and [key bindings][vsckeybindings] that are inspired by [Kakoune].
- Some features are provided to mimic Kakoune's behavior (e.g. treating positions as coordonates
of characters, rather than carets between characters like VS Code), but are optional.
- Kakoune, Vim and VS Code are all fully-fledged text editors; therefore, they have overlapping
features. For instance, where [VSCodeVim] provides its own multi-cursor and command engines
to feel more familiar to existing Vim users, Dance leaves multi-cursor mode and editor
commands to VS Code entirely.
- Unlike [VSCodeVim] which attempts to emulate Vim, Dance's only goal is to
provide VS Code-native [commands][vsccommands] and
[key bindings][vsckeybindings] that are inspired by [Kakoune].
- Some features are provided to mimic Kakoune's behavior (e.g. treating
positions as coordonates of characters, rather than carets between
characters like VS Code), but are optional.
- Kakoune, Vim and VS Code are all fully-fledged text editors; therefore, they
have overlapping features. For instance, where [VSCodeVim] provides its own
multi-cursor and command engines to feel more familiar to existing Vim users,
Dance leaves multi-cursor mode and editor commands to VS Code entirely.
## User Guide
For most [commands], the usage is the same as in [Kakoune]. However, the following changes have been made:
For most [commands], the usage is the same as in [Kakoune]. However, the
following changes have been made:
### Custom modes
All modes are custom. By default, the `normal` and `insert` modes are defined,
and many [Kakoune]-inspired keybindings are available. More modes can be
created, though. These modes are configured with `dance.modes`.
### Selection behaviors
Dance by default uses caret-based selections just like VSCode. This means a selection is anchored between two carets
(i.e. positions between characters), and may be empty.
Dance by default uses caret-based selections just like VS Code. This means a
selection is anchored between two carets (i.e. positions between characters),
and may be empty.
If you prefer character-based selections like Kakoune, please set `"dance.selectionBehavior": "character"` in your
settings. This will make Dance treat selections as inclusive ranges between two characters, and implies that each
selection will contain at least one character. (This behavior is recommended for Kakoune-users who have already
developed muscle memory, e.g. hitting `;d` to delete one character.)
If you prefer character-based selections like Kakoune, please set
`"selectionBehavior": "character"` in the configuration of the mode in which you
wish to use character-based selections. This mode is designed to work with
block-style cursors, so your configuration would typically look like:
```json
"dance.modes": {
"insert": {
// ...
},
"normal": {
"cursorStyle": "block",
"selectionBehavior": "character",
// ...
}
},
```
If this is enabled, Dance will internally treat selections as inclusive ranges
between two characters and imply that each selection contains at least one
character.
### Scripting
Most keybindings exposed by Dance are actually implemented by running several
Dance commands in a row. For instance, `dance.modes.set.normal` is actually a
wrapper around `dance.modes.set` with the argument `{ input: "normal" }`.
Commands that take an input, like `dance.modes.set`, will prompt a user for a
value if no argument is given.
Additionally to having commands with many settings, Dance also exposes the
`dance.run` command, which runs JavaScript code. That code has access to the
[Dance API][API], and can perform operations with more control than Dance
commands. Where Dance commands in the `dance.selections` namespace operate the
same way on all selections at once, `dance.run` can be used to individually
manipulate selections.
Finally, the [Dance API][API] is exported by Dance. Other VS Code extensions
can specify that they depend on Dance (with the [`extensionDependencies`
property](https://code.visualstudio.com/api/references/extension-manifest#fields)),
and then access the API by calling [`activate`](
https://code.visualstudio.com/api/references/vscode-api#Extension.activate):
```js
const { api } = await vscode.extensions.getExtension("gregoire.dance").activate();
```
### Pipes
- Pipes no longer accept shell commands, but instead accept 'expressions', those being:
Pipes no longer accept shell commands, but instead accept "expressions", those
being:
- `#<shell command>`: Pipes each selection into a shell command (the shell is
taken from the `terminal.external.exec` value).
- `/<pattern>[/<replacement>[/<flags>]`: A RegExp literal, as
[defined in JavaScript](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions).
Do note the addition of a `replacement`, for commands that add or replace
text.
- `<JS expression>`: A JavaScript expression in which the following variables
are available:
- `$`: Text of the current selection.
- `$$`: Array of the text of all the selections.
- `i`: Index of the current selection.
- `n`: Number of selections in `$$`.
- `#<shell command>`: Pipes each selection into a shell command (the shell is taken from the `terminal.external.exec` value).
- `/<pattern>[/<replacement>[/<flags>]`: A RegExp literal, as [defined in JavaScript](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions). Do note the addition of a `replacement`, for commands that add or replace text.
- `<JS expression>`: A JavaScript expression in which the following variables are available:
- `$`: Text of the current selection.
- `$$`: Array of the text of all the selections.
- `i`: Index of the current selection.
Depending on the result of the expression, it will be inserted differently:
- `string`: Inserted directly.
- `number`: Inserted in its string representation.
- `boolean`: Inserted as `true` or `false`.
- `null`: Inserted as `null`.
- `undefined`: Inserted as an empty string.
- `object`: Inserted as JSON.
- Any other type: Leads to an error.
Depending on the result of the expression, it will be inserted differently:
- `string`: Inserted directly.
- `number`: Inserted in its string representation.
- `boolean`: Inserted as `true` or `false`.
- `null`: Inserted as `null`.
- `undefined`: Inserted as an empty string.
- `object`: Inserted as JSON.
- Any other type: Leads to an error.
#### Examples
- `/(\d+),(\d+)/$1.$2/g` replaces `12,34` into `12.34`.
- `i + 1` replaces `1,1,1,1,1` into `1,2,3,4,5`, assuming that each selection is on a different digit.
- `i + 1` replaces `1,1,1,1,1` into `1,2,3,4,5`, assuming that each selection is
on a different digit.
### Miscellaneous changes
A few changes were made from Kakoune, mostly out of personal preference, and to make the
extension integrate better in VS Code. If you disagree with any of these changes,
you're welcome to open an issue to discuss it, or to add an option for it by submitting a PR.
A few changes were made from Kakoune, mostly out of personal preference, and to
make the extension integrate better with VS Code.
- The cursor is not a block, but a line: Dance focuses on selections, and using a line instead of
a block makes it obvious whether zero or one characters are selected. Besides, the line-shaped
cursor is the default in VS Code.
- Changing the mode will also change the `editor.lineNumbers` configuration value to `on` in `insert`
mode, and `relative` in normal mode.
- The default yank register `"` maps to the system clipboard.
- There are some additional features not documented here but mentioned in [issues] and/or in
the configuration of the plugin. TODO: document them here.
- When using the default configuration (that is to say, these settings can be
modified):
- The cursor is not a block, but a line: Dance focuses on selections, and
using a line instead of a block makes it obvious whether zero or one
characters are selected. Besides, the line-shaped cursor is the default in
VS Code.
- Changing the mode will also change the `editor.lineNumbers` configuration
value to `on` in `insert` mode, and `relative` in normal mode.
### Troubleshooting
Dance uses the built-in VS Code key bindings, and therefore does not override the `type` command.
**However**, it sometimes needs access to the `type` command, in dialogs and register selection,
for instance. Consequently, it is not compatible with extensions that always override the `type`
command, such as [VSCodeVim]; these extensions must therefore be disabled.
## Progress
This project is still a WIP. It has gotten better over the years, but may have annoying bugs
and lack some features, especially for Kakoune users. Despite this, several users use Dance
daily.
In the following list, if a command is implemented, then its extending equivalent
(activated while pressing `Shift`) then likely is implemented as well.
Most (but not all) commands defined in [`commands`][commands] are implemented.
- [x] Basic movements:
- [x] Arrows, hjkl.
- [x] Move to character, move until character.
- [x] Move to next word, move to previous word.
- [x] Insert mode:
- [x] Enter insert mode with `a`, `i`, `o`, and their `Alt` / `Shift` equivalents.
- [x] Exit insert mode with `Escape`.
- [x] Basic selections:
- [x] Search in selections.
- [x] Split in selections.
- [x] Split selections by lines.
- [x] Extend selections by taking lines.
- [x] Trim selections.
- [x] Pipes.
- [x] Object selection.
- [x] Yanking:
- [x] Yank.
- [x] Paste.
- [x] Rotate:
- [x] Rotate selections only.
- [x] Rotate selections content only.
- [x] Rotate selections and content.
- [x] Changes:
- [x] Join.
- [x] Replace.
- [x] Delete.
- [x] Indent.
- [x] Dedent.
- [x] Change case.
- [x] Search.
- [ ] History:
- [x] Undo / redo.
- [ ] Forward / backward.
- [x] Repeat command.
- [ ] Repeat insertion.
- [x] Macros.
- [ ] Registers.
- Dance uses the built-in VS Code key bindings, and therefore does not override
the `type` command. **However**, it sometimes needs access to the `type`
command, in dialogs and register selection, for instance. Consequently, it is
not compatible with extensions that always override the `type` command, such
as [VSCodeVim]; these extensions must therefore be disabled.
- If you're on Linux and your keybindings don't work as expected (for instance,
`swapescape` is not respected), take a look at the [VS Code guide for
troubleshooting Linux keybindings](
https://github.com/Microsoft/vscode/wiki/Keybinding-Issues#troubleshoot-linux-keybindings).
TL;DR: adding `"keyboard.dispatch": "keyCode"` to your VS Code settings will
likely fix it.
## Contributing
### Plugins
### Bugs
Dance was designed to nicely interopate with other extensions: it does not override
the `type` command, and allows any extension to execute its commands.
It should therefore be possible to create other extensions that work with Dance. If
you'd like to add new features to Dance directly, please file an issue.
There are unfortunately still bugs lurking around. If you find one, please
ensure that it has not been reported yet and submit a [test](./test/README.md)
that does not pass and can be used to reliably reproduce the bug.
### Bugs and features
### Features
There are unfortunately still bugs lurking around features missing. If you'd like to
fix bugs or add features, please look at the [issues] and file one if no other issue
matches your request. This will ensure that no two people work on the same feature
at the same time, and will be a good place to ask for help in case you want
to tackle this yourself.
If you'd like to add or improve a feature, please make sure that no similar
feature has been requested in the [issues] and file a new issue for it. This
will ensure that no two people work on the same feature at the same time, and
will be a good place to ask for help in case you want to tackle this yourself.
Since some features are not general enough, it may be requested of you to make a
plugin that uses the Dance API or to simply use scripts in the meantime.
When contributing, please be mindful of the existing coding conventions and naming.
When contributing, please be mindful of the existing coding conventions and
naming.
Your PR will be rebased on top of `master` in order to keep a clean commit history.
Please avoid unnecessary commits (`git commit --amend` is your friend).
Your PR will be rebased on top of `master` in order to keep a clean commit
history. Please avoid unnecessary commits (`git commit --amend` is your friend).
### Tests
We recently started adding tests to Dance. Most tests are in `test/suite/commands`,
as plain text files that are separated into several sections.
Tests can be run and debugged in VS Code in the run menu, under "Run Extension Tests".
#### Sections
Each section has a name, which is any string that has no whitespace.
Except for the first section (implicitly named `0` or `root`), each section
is associated with some transition that consists of several Dance commands to run.
For instance, let's look at the following code:
```
...
//== 0 > 1
//= dance.select.line
...
//== 1 > 2
//= dance.select.line.extend
...
//== 1 > 3
//= dance.select.line
//= dance.select.line.extend
...
```
It defines three sections:
- `1`, which is reached after executing `dance.select.line` from section `0`.
- `2`, which is reached after executing `dance.select.line.extend` from section `1`.
- `3`, which is reached after executing `dance.select.line` and then `dance.select.line.extend` from section `1`.
As you can see, several sections can depend on the same parent section. Do note that
sections must be defined in order; that is, a section `a` cannot depend on a section `b`
if section `b` is defined after `a`.
#### Section content
Each section has content (the `...` in the example above). That content is plain text to which
one or more selections must be added using a `{...}` / `|{...}` syntax, where `...` is a number.
`{0}` represents the anchor of the 1st selection, and `|{2}` represents the active position of the 3rd selection.
Selections can be given in any order, but must be complete; that is, if a selection `3` is given, then the
selections `0`, `1`, and `2` must be defined at some point too. The anchor can be omitted, and will default to
the active position.
#### Tests generation
For each transition, a test will be generated making sure that executing the corresponding commands
will lead to some document with selections at some locations.
Let's look at the following code:
```
{0}f|{0}oo
//== 0 > 1
//= dance.right
f{0}o|{0}o
//== 1 > 2
//= dance.delete.yank
f{0}o|{0}
```
The first generated test asserts that calling `dance.right` in the document `foo` where `f` is the main selection
leads to a document `foo` with the first `o` selected.
The second generated test asserts that calling `dance.delete.yank` in the document `foo` where the first `o` is
the main selection leads to a document `fo` with `o` selected.
#### Other features
- Command test files ending with `.caret` will we run with `selectionBehavior == "caret"`. Otherwise,
`selectionBehavior == "character"` is used.
- Comments can be added by having lines start with "// ".
- When arguments must be passed to the command, JSON can be used, e.g.
```
//= {"command": "dance.objects.performSelection", "args": [{"object": "parens", "action": "selectToEnd"}]}
```
- When a command awaits a key press, it can be added **after** the command, e.g.
```
//= dance.select.to.included
//= type:c
```
[commands]: ./commands
[api]: ./src/api
[commands]: ./src/commands
[issues]: https://github.com/71/dance/issues
[vim]: https://www.vim.org
[kakoune]: https://github.com/mawww/kakoune

BIN
assets/dance.afdesign Normal file

Binary file not shown.

BIN
assets/dance.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

39
assets/dance.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 29 KiB

View File

@ -1,199 +0,0 @@
Commands
========
<!-- Auto-generated by commands/generate.ts. Do not edit manually. -->
Commands are defined in [`commands.yaml`](./commands.yaml), and then exported
to VS Code-compatible [commands](https://code.visualstudio.com/api/extension-guides/command)
and [key bindings](https://code.visualstudio.com/docs/getstarted/keybindings).
They are implemented in [`src/commands`](../src/commands).
| ID | Title | Description | Key bindings |
| -- | ----- | ----------- | ------------ |
| `dance.toggle` | Toggle | Toggles Dance key bindings. | |
| `dance.set.normal` | Set mode to Normal | Set Dance mode to Normal. | `Escape` (`dance.mode == 'insert'`) |
| `dance.set.insert` | Set mode to Insert | Set Dance mode to Insert. | |
| `dance.tmp.normal` | Temporary normal mode | Switches to normal mode temporarily. | `Ctrl+V` (`dance.mode == 'insert'`) |
| `dance.tmp.insert` | Temporary insert mode | Switches to insert mode temporarily. | `Ctrl+V` (`dance.mode == 'normal'`) |
| `dance.insert.before` | Insert before | Start insert before the current selections. | `I` (`dance.mode == 'normal'`) |
| `dance.insert.after` | Insert after | Start insert after the current selections. | `A` (`dance.mode == 'normal'`) |
| `dance.insert.lineStart` | Insert at line start | Start insert at line start of each selection. | `Shift+I` (`dance.mode == 'normal'`) |
| `dance.insert.lineEnd` | Insert at line end | Start insert at line end of each selection. | `Shift+A` (`dance.mode == 'normal'`) |
| `dance.insert.newLine.below` | Insert new line below | Create new line and start insert below. | `O` (`dance.mode == 'normal'`) |
| `dance.insert.newLine.above` | Insert new line above | Create new line and start insert above. | `Shift+O` (`dance.mode == 'normal'`) |
| `dance.newLine.below` | Add new line below | Add a new line below, without entering insert mode. | `Alt+O` (`dance.mode == 'normal'`) |
| `dance.newLine.above` | Add new line above | Add a new line above, without entering insert mode. | `Shift+Alt+O` (`dance.mode == 'normal'`) |
| `dance.repeat.insert` | Repeat last insert-mode change | Repeat last insert-mode change. | `.` (`dance.mode == 'normal'`) |
| `dance.repeat.objectOrSelectTo` | Repeat last object select / character find | Repeat last object select / character find. | `Alt+.` (`dance.mode == 'normal'`) |
| `dance.left` | Move left | Move left. | `Left` (`dance.mode == 'normal'`), `H` (`dance.mode == 'normal'`) |
| `dance.right` | Move right | Move right. | `Right` (`dance.mode == 'normal'`), `L` (`dance.mode == 'normal'`) |
| `dance.up` | Move up | Move up. | `Up` (`dance.mode == 'normal'`), `K` (`dance.mode == 'normal'`) |
| `dance.down` | Move down | Move down. | `Down` (`dance.mode == 'normal'`), `J` (`dance.mode == 'normal'`) |
| `dance.up.page` | Scroll one page up | Scroll one page up. | `Ctrl+B` (`dance.mode == 'normal'`), `Ctrl+B` (`dance.mode == 'insert'`) |
| `dance.down.page` | Scroll one page down | Scroll one page down. | `Ctrl+F` (`dance.mode == 'normal'`), `Ctrl+F` (`dance.mode == 'insert'`) |
| `dance.up.halfPage` | Scroll half a page up | Scroll half a page up. | `Ctrl+U` (`dance.mode == 'normal'`), `Ctrl+U` (`dance.mode == 'insert'`) |
| `dance.down.halfPage` | Scroll half a page down | Scroll half a page down. | `Ctrl+D` (`dance.mode == 'normal'`), `Ctrl+D` (`dance.mode == 'insert'`) |
| `dance.select.to.included` | Select to | Select to the next character pressed, including it. | `F` (`dance.mode == 'normal'`) |
| `dance.select.to.excluded` | Select until | Select until the next character pressed, excluding it. | `T` (`dance.mode == 'normal'`) |
| `dance.select.buffer` | Select whole buffer | Select whole buffer. | `Shift+5` (`dance.mode == 'normal'`) |
| `dance.select.line` | Select line | Select line on which the end of each selection lies (or next line when end lies on an end-of-line). | `X` (`dance.mode == 'normal'`) |
| `dance.select.toLineBegin` | Select to line beginning | Select to line beginning. | `Alt+H` (`dance.mode == 'normal'`), `Home` (`dance.mode == 'normal'`) |
| `dance.select.toLineEnd` | Select to line end | Select to line end. | `Alt+L` (`dance.mode == 'normal'`), `End` (`dance.mode == 'normal'`) |
| `dance.select.enclosing` | Select enclosing characters | Select enclosing characters. | `M` (`dance.mode == 'normal'`) |
| `dance.expandLines` | Extend lines | Extend selections to contain full lines (including end-of-lines). | `Alt+X` (`dance.mode == 'normal'`) |
| `dance.trimLines` | Trim lines | Trim selections to only contain full lines (from start to line break). | `Shift+Alt+X` (`dance.mode == 'normal'`) |
| `dance.trimSelections` | Trim selections | Trim whitespace at beginning and end of selections. | `Shift+-` (`dance.mode == 'normal'`) |
| `dance.select.word` | Select to next word start | Select the word and following whitespaces on the right of the end of each selection. | `W` (`dance.mode == 'normal'`) |
| `dance.select.word.previous` | Select to previous word start | Select preceding whitespaces and the word on the left of the end of each selection. | `B` (`dance.mode == 'normal'`) |
| `dance.select.word.end` | Select to next word end | Select preceding whitespaces and the word on the right of the end of each selection. | `E` (`dance.mode == 'normal'`) |
| `dance.select.word.alt` | Select to next non-whitespace word start | Select the non-whitespace word and following whitespaces on the right of the end of each selection. | `Alt+W` (`dance.mode == 'normal'`) |
| `dance.select.word.alt.previous` | Select to previous non-whitespace word start | Select preceding whitespaces and the non-whitespace word on the left of the end of each selection. | `Alt+B` (`dance.mode == 'normal'`) |
| `dance.select.word.alt.end` | Select to next non-whitespace word end | Select preceding whitespaces and the non-whitespace word on the right of the end of each selection. | `Alt+E` (`dance.mode == 'normal'`) |
| `dance.select` | Select | Select within current selections according to a RegExp. | `S` (`dance.mode == 'normal'`) |
| `dance.split` | Split | Split within current selections according to a RegExp. | `Shift+S` (`dance.mode == 'normal'`) |
| `dance.split.lines` | Split lines | Split selections into lines. | `Alt+S` (`dance.mode == 'normal'`) |
| `dance.select.firstLast` | Select first and last characters | Select first and last characters of each selection. | `Shift+Alt+S` (`dance.mode == 'normal'`) |
| `dance.select.copy` | Copy selection to next line | Copy selection to next line. | `Shift+C` (`dance.mode == 'normal'`) |
| `dance.select.copy.backwards` | Copy selection to previous line | Copy selection to previous line. | `Shift+Alt+C` (`dance.mode == 'normal'`) |
| `dance.selections.reduce` | Reduce selections | Reduce selections to their cursor. | `;` (`dance.mode == 'normal'`) |
| `dance.selections.flip` | Flip selections | Flip the direction of each selection. | `Alt+;` (`dance.mode == 'normal'`) |
| `dance.selections.forward` | Forward selections | Ensure selections are in forward direction (the active cursor is after the anchor). | `Shift+Alt+;` (`dance.mode == 'normal'`) |
| `dance.selections.backward` | Backward selections | Ensure selections are in backward direction (the active cursor is before the anchor). | |
| `dance.selections.clear` | Clear selections | Clear selections (except main) | `Space` (`dance.mode == 'normal'`) |
| `dance.selections.clearMain` | Clear main selection | Clear main selection. | `Alt+Space` (`dance.mode == 'normal'`) |
| `dance.selections.keepMatching` | Keep matching selections | Keep selections that match a RegExp. | `Alt+K` (`dance.mode == 'normal'`) |
| `dance.selections.clearMatching` | Clear matching selections | Clear selections that match a RegExp. | `Shift+Alt+K` (`dance.mode == 'normal'`) |
| `dance.selections.merge` | Merge contiguous selections | Merge contiguous selections together, including across lines. | `Shift+Alt+-` (`dance.mode == 'normal'`) |
| `dance.selections.align` | Align selections | Align selections, aligning the cursor of each selection by inserting spaces before the first character of each selection. | `Shift+7` (`dance.mode == 'normal'`) |
| `dance.selections.align.copy` | Copy indentation | Copy the indentation of the main selection (or the count one if a count is given) to all other ones. | `Shift+Alt+7` (`dance.mode == 'normal'`) |
| `dance.delete.yank` | Yank and delete | Yank and delete selections. | `D` (`dance.mode == 'normal'`) |
| `dance.delete.insert.yank` | Yank, delete and insert | Yank, delete and enter insert mode. | `C` (`dance.mode == 'normal'`) |
| `dance.delete.noYank` | Delete without yank | Delete selections without yanking. | `Alt+D` (`dance.mode == 'normal'`) |
| `dance.delete.insert.noYank` | Delete and insert without yank | Delete selections without yanking and enter insert mode. | `Alt+C` (`dance.mode == 'normal'`) |
| `dance.yank` | Yank | Yank selections. | `Y` (`dance.mode == 'normal'`) |
| `dance.paste.after` | Paste after | Paste after the end of each selection. | `P` (`dance.mode == 'normal'`) |
| `dance.paste.before` | Paste before | Paste before the start of each selection. | `Shift+P` (`dance.mode == 'normal'`) |
| `dance.paste.select.after` | Paste after and select | Paste after the end of each selection and select pasted text. | `Alt+P` (`dance.mode == 'normal'`) |
| `dance.paste.select.before` | Paste before and select | Paste before the start of each selection and select pasted text. | `Shift+Alt+P` (`dance.mode == 'normal'`) |
| `dance.paste.replace` | Replace | Replace selections with yanked text. | `Shift+R` (`dance.mode == 'normal'`) |
| `dance.paste.replace.every` | Replace with every | Replace selections with every yanked text. | `Shift+Alt+R` (`dance.mode == 'normal'`) |
| `dance.replace.characters` | Replace character | Replace each selected character with the next entered one. | `R` (`dance.mode == 'normal'`) |
| `dance.join` | Join lines | Join selected lines. | `Alt+J` (`dance.mode == 'normal'`) |
| `dance.join.select` | Join lines and select spaces | Join selected lines and select spaces inserted in place of line breaks. | `Shift+Alt+J` (`dance.mode == 'normal'`) |
| `dance.indent` | Indent | Indent selected lines. | `Shift+.` (`dance.mode == 'normal'`) |
| `dance.indent.withEmpty` | Indent (including empty) | Indent selected lines (including empty lines). | `Shift+Alt+.` (`dance.mode == 'normal'`) |
| `dance.deindent` | Deindent | Deindent selected lines. | `Shift+Alt+,` (`dance.mode == 'normal'`) |
| `dance.deindent.further` | Deindent (including incomplete indent) | Deindent selected lines (and remove additional incomplete indent). | `Shift+,` (`dance.mode == 'normal'`) |
| `dance.toLowerCase` | Transform to lowercase | Transform to lowercase. | ``` (`dance.mode == 'normal'`) |
| `dance.toUpperCase` | Transform to uppercase | Transform to uppercase. | `Shift+`` (`dance.mode == 'normal'`) |
| `dance.swapCase` | Swap case | Swap case. | `Alt+`` (`dance.mode == 'normal'`) |
| `dance.pipe.filter` | Filter through pipe | Pipe each selection to a program, and keeps it if the program returns 0. | `Shift+4` (`dance.mode == 'normal'`) |
| `dance.pipe.replace` | Pipe and replace | Pipe each selection to a command, and replaces it with its output. | `Shift+\` (`dance.mode == 'normal'`) |
| `dance.pipe.ignore` | Pipe | Pipe each selection to a command, ignoring their results. | `Shift+Alt+\` (`dance.mode == 'normal'`) |
| `dance.pipe.append` | Pipe and append | Pipe each selection to a command, appending the output after the selection. | `Shift+1` (`dance.mode == 'normal'`) |
| `dance.pipe.prepend` | Pipe and prepend | Pipe each selection to a command, prepending the output before the selection. | `Shift+Alt+1` (`dance.mode == 'normal'`) |
| `dance.history.undo` | Undo | Undo. | `U` (`dance.mode == 'normal'`) |
| `dance.history.backward` | Move backward in history | Move backward in history. | `Alt+U` (`dance.mode == 'normal'`) |
| `dance.history.redo` | Redo | Redo. | `Shift+U` (`dance.mode == 'normal'`) |
| `dance.history.forward` | Move forward in history | Move forward in history. | `Shift+Alt+U` (`dance.mode == 'normal'`) |
| `dance.history.repeat` | Repeat last change | Repeat last change. | |
| `dance.history.repeat.selection` | Repeat last selection change | Repeat last selection change. | |
| `dance.history.repeat.edit` | Repeat last edit change | Repeat last edit change. | |
| `dance.macros.record.start` | Start recording macro | Start recording macro. | `Shift+Q` (`dance.mode == 'normal' && !dance.recordingMacro`) |
| `dance.macros.record.stop` | Stop recording macro | Stop recording macro. | `Escape` (`dance.mode == 'normal'`) |
| `dance.macros.play` | Play macro | Play macro. | `Q` (`dance.mode == 'normal'`) |
| `dance.rotate` | Rotate | Rotate each selection clockwise. | `Shift+9` (`dance.mode == 'normal'`) |
| `dance.rotate.backwards` | Rotate backwards | Rotate each selection counter-clockwise. | `Shift+0` (`dance.mode == 'normal'`) |
| `dance.rotate.content` | Rotate selection content | Rotate each selection (as well as its content) clockwise. | `Shift+Alt+9` (`dance.mode == 'normal'`) |
| `dance.rotate.content.backwards` | Rotate selection content backwards | Rotate each selection (as well as its content) counter-clockwise. | `Shift+Alt+0` (`dance.mode == 'normal'`) |
| `dance.rotate.contentOnly` | Rotate content only | Rotate each selection content clockwise, without changing selections. | |
| `dance.rotate.contentOnly.backwards` | Rotate content only backwards | Rotate each selection content counter-clockwise, without changing selections. | |
| `dance.search` | Search | Search for the given input string. | `/` (`dance.mode == 'normal'`) |
| `dance.search.backwards` | Search backwards | Search for the given input string before the current selections. | `Alt+/` (`dance.mode == 'normal'`) |
| `dance.search.selection.smart` | Search current selections (smart) | Search current selections (smart). | `Shift+8` (`dance.mode == 'normal'`) |
| `dance.search.selection` | Search current selections | Search current selections. | `Shift+Alt+8` (`dance.mode == 'normal'`) |
| `dance.search.next` | Select next match | Select next match after the main selection. | `N` (`dance.mode == 'normal'`) |
| `dance.search.next.add` | Add next match | Add a new selection with the next match after the main selection. | `Shift+N` (`dance.mode == 'normal'`) |
| `dance.search.previous` | Select previous match | Select previous match before the main selection. | `Alt+N` (`dance.mode == 'normal'`) |
| `dance.search.previous.add` | Add previous match | Add a new selection with the previous match before the main selection. | `Shift+Alt+N` (`dance.mode == 'normal'`) |
| | Select whole object | Select whole object. | `Alt+A` (`dance.mode == 'normal'`), `Alt+A` (`dance.mode == 'insert'`) |
| | Select inner object | Select inner object. | `Alt+I` (`dance.mode == 'normal'`), `Alt+I` (`dance.mode == 'insert'`) |
| | Select to the whole object start | Select to the whole object start. | `[` (`dance.mode == 'normal'`) |
| | Select to the whole object end | Select to the whole object end. | `]` (`dance.mode == 'normal'`) |
| `dance.objects.performSelection` | Perform selections specified in the arguments. | Perform selections specified in the arguments.. | |
| `dance.goto` | Go to... | Shows prompt to jump somewhere | `G` (`dance.mode == 'normal'`) |
| `dance.goto.lineStart` | Go to line start | Go to line start. | |
| `dance.goto.lineStart.nonBlank` | Go to non-blank line start | Go to first non-whitespace character of the line | |
| `dance.goto.lineEnd` | Go to line end | Go to line end. | |
| `dance.goto.firstLine` | Go to first line | Go to first line. | |
| `dance.goto.lastLine` | Go to last line | Go to last line. | |
| `dance.goto.lastCharacter` | Go to last character of the document | Go to last character of the document. | |
| `dance.goto.firstVisibleLine` | Go to first visible line | Go to first visible line. | |
| `dance.goto.middleVisibleLine` | Go to middle visible line | Go to middle visible line. | |
| `dance.goto.lastVisibleLine` | Go to last visible line | Go to last visible line. | |
| `dance.goto.selectedFile` | Open file under selection | Open file under selection. | |
| `dance.goto.lastModification` | Go to last buffer modification position | Go to last buffer modification position. | |
| `dance.openMenu` | Open quick-jump menu | Open quick-jump menu. | |
| `dance.registers.insert` | Insert value in register | Insert value in register. | `Ctrl+R` (`dance.mode == 'normal'`), `Ctrl+R` (`dance.mode == 'insert'`) |
| `dance.registers.select` | Select register for next command | Select register for next command. | `Shift+\'` (`dance.mode == 'normal'`) |
| `dance.marks.saveSelections` | Save selections | Save selections. | `Shift+Z` (`dance.mode == 'normal'`) |
| `dance.marks.restoreSelections` | Restore selections | Restore selections. | `Z` (`dance.mode == 'normal'`) |
| `dance.marks.combineSelections.fromCurrent` | Combine current selections with ones from register | Combine current selections with ones from register. | `Shift+Alt+Z` (`dance.mode == 'normal'`) |
| `dance.marks.combineSelections.fromRegister` | Combine register selections with current ones | Combine register selections with current ones. | `Alt+Z` (`dance.mode == 'normal'`) |
| `dance.cancel` | Cancel operation | Cancels waiting for input from the user | `Escape` (`dance.mode == 'awaiting'`) |
| `dance.run` | Run code | Runs JavaScript code passed in a 'code' argument | |
| | Open Command Palette | Open the built-in Command Palette in VSCode | `Shift+;` (`dance.mode == 'normal'`) |
| `dance.left.extend` | Move left (extend) | Move left (extend). | `Shift+Left` (`dance.mode == 'normal'`), `Shift+H` (`dance.mode == 'normal'`) |
| `dance.right.extend` | Move right (extend) | Move right (extend). | `Shift+Right` (`dance.mode == 'normal'`), `Shift+L` (`dance.mode == 'normal'`) |
| `dance.up.extend` | Move up (extend) | Move up (extend). | `Shift+Up` (`dance.mode == 'normal'`), `Shift+K` (`dance.mode == 'normal'`) |
| `dance.down.extend` | Move down (extend) | Move down (extend). | `Shift+Down` (`dance.mode == 'normal'`), `Shift+J` (`dance.mode == 'normal'`) |
| `dance.select.to.included.extend` | Extend to | Extend to the next character pressed, including it. | `Shift+F` (`dance.mode == 'normal'`) |
| `dance.select.to.excluded.extend` | Extend until | Extend with until the next character pressed, excluding it. | `Shift+T` (`dance.mode == 'normal'`) |
| `dance.select.line.extend` | Extend with line | Extend with line on which the end of each selection lies (or next line when end lies on an end-of-line). | `Shift+X` (`dance.mode == 'normal'`) |
| `dance.select.toLineBegin.extend` | Extend to line beginning | Extend to line beginning. | `Shift+Alt+H` (`dance.mode == 'normal'`), `Shift+Home` (`dance.mode == 'normal'`) |
| `dance.select.toLineEnd.extend` | Extend to line end | Extend to line end. | `Shift+Alt+L` (`dance.mode == 'normal'`), `Shift+End` (`dance.mode == 'normal'`) |
| `dance.select.enclosing.extend` | Extend with enclosing characters | Extend with enclosing characters. | `Shift+M` (`dance.mode == 'normal'`) |
| `dance.select.word.extend` | Extend to next word start | Extend with the word and following whitespaces on the right of the end of each selection. | `Shift+W` (`dance.mode == 'normal'`) |
| `dance.select.word.previous.extend` | Extend to previous word start | Extend with preceding whitespaces and the word on the left of the end of each selection. | `Shift+B` (`dance.mode == 'normal'`) |
| `dance.select.word.end.extend` | Extend to next word end | Extend with preceding whitespaces and the word on the right of the end of each selection. | `Shift+E` (`dance.mode == 'normal'`) |
| `dance.select.word.alt.extend` | Extend to next non-whitespace word start | Extend with the non-whitespace word and following whitespaces on the right of the end of each selection. | `Shift+Alt+W` (`dance.mode == 'normal'`) |
| `dance.select.word.alt.previous.extend` | Extend to previous non-whitespace word start | Extend with preceding whitespaces and the non-whitespace word on the left of the end of each selection. | `Shift+Alt+B` (`dance.mode == 'normal'`) |
| `dance.select.word.alt.end.extend` | Extend to next non-whitespace word end | Extend with preceding whitespaces and the non-whitespace word on the right of the end of each selection. | `Shift+Alt+E` (`dance.mode == 'normal'`) |
| `dance.search.extend` | Search (extend) | Search for the given input string (extend). | `Shift+/` (`dance.mode == 'normal'`) |
| `dance.search.backwards.extend` | Search backwards (extend) | Search for the given input string before the current selections (extend). | `Shift+Alt+/` (`dance.mode == 'normal'`) |
| | Extend to the whole object start | Extend to the whole object start. | `Shift+[` (`dance.mode == 'normal'`) |
| | Extend to the whole object end | Extend to the whole object end. | `Shift+]` (`dance.mode == 'normal'`) |
| `dance.goto.extend` | Go to... (extend) | Shows prompt to jump somewhere | `Shift+G` (`dance.mode == 'normal'`) |
| `dance.goto.lineStart.extend` | Go to line start (extend) | Go to line start (extend). | |
| `dance.goto.lineStart.nonBlank.extend` | Go to non-blank line start (extend) | Go to first non-whitespace character of the line | |
| `dance.goto.lineEnd.extend` | Go to line end (extend) | Go to line end (extend). | |
| `dance.goto.firstLine.extend` | Go to first line (extend) | Go to first line (extend). | |
| `dance.goto.lastLine.extend` | Go to last line (extend) | Go to last line (extend). | |
| `dance.goto.lastCharacter.extend` | Go to last character of the document (extend) | Go to last character of the document (extend). | |
| `dance.goto.firstVisibleLine.extend` | Go to first visible line (extend) | Go to first visible line (extend). | |
| `dance.goto.middleVisibleLine.extend` | Go to middle visible line (extend) | Go to middle visible line (extend). | |
| `dance.goto.lastVisibleLine.extend` | Go to last visible line (extend) | Go to last visible line (extend). | |
| `dance.goto.lastModification.extend` | Go to last buffer modification position (extend) | Go to last buffer modification position (extend). | |
| `dance.select.to.included.backwards` | Select to (backwards) | Select to the next character pressed, including it. (backwards) | `Alt+F` (`dance.mode == 'normal'`) |
| `dance.select.to.excluded.backwards` | Select until (backwards) | Select until the next character pressed, excluding it. (backwards) | `Alt+T` (`dance.mode == 'normal'`) |
| `dance.select.enclosing.backwards` | Select enclosing characters (backwards) | Select enclosing characters. (backwards) | `Alt+M` (`dance.mode == 'normal'`) |
| `dance.select.to.included.extend.backwards` | Extend to (backwards) | Extend to the next character pressed, including it. (backwards) | `Alt+Shift+F` (`dance.mode == 'normal'`) |
| `dance.select.to.excluded.extend.backwards` | Extend until (backwards) | Extend with until the next character pressed, excluding it. (backwards) | `Alt+Shift+T` (`dance.mode == 'normal'`) |
| `dance.select.enclosing.extend.backwards` | Extend with enclosing characters (backwards) | Extend with enclosing characters. (backwards) | `Alt+Shift+M` (`dance.mode == 'normal'`) |
| | Select to the inner object start | Select to the inner object start. | `Alt+[` (`dance.mode == 'normal'`) |
| | Select to the inner object end | Select to the inner object end. | `Alt+]` (`dance.mode == 'normal'`) |
| | Extend to the inner object start | Extend to the inner object start. | `Alt+Shift+[` (`dance.mode == 'normal'`) |
| | Extend to the inner object end | Extend to the inner object end. | `Alt+Shift+]` (`dance.mode == 'normal'`) |
| `dance.count.0` | Count 0 | Adds 0 to the current counter for the next operation. | `0` (`dance.mode == 'normal'`) |
| `dance.count.1` | Count 1 | Adds 1 to the current counter for the next operation. | `1` (`dance.mode == 'normal'`) |
| `dance.count.2` | Count 2 | Adds 2 to the current counter for the next operation. | `2` (`dance.mode == 'normal'`) |
| `dance.count.3` | Count 3 | Adds 3 to the current counter for the next operation. | `3` (`dance.mode == 'normal'`) |
| `dance.count.4` | Count 4 | Adds 4 to the current counter for the next operation. | `4` (`dance.mode == 'normal'`) |
| `dance.count.5` | Count 5 | Adds 5 to the current counter for the next operation. | `5` (`dance.mode == 'normal'`) |
| `dance.count.6` | Count 6 | Adds 6 to the current counter for the next operation. | `6` (`dance.mode == 'normal'`) |
| `dance.count.7` | Count 7 | Adds 7 to the current counter for the next operation. | `7` (`dance.mode == 'normal'`) |
| `dance.count.8` | Count 8 | Adds 8 to the current counter for the next operation. | `8` (`dance.mode == 'normal'`) |
| `dance.count.9` | Count 9 | Adds 9 to the current counter for the next operation. | `9` (`dance.mode == 'normal'`) |

View File

@ -1,687 +0,0 @@
# Most commands defined below come from Kakoune:
# - https://github.com/mawww/kakoune/blob/master/src/normal.cc
# - https://github.com/mawww/kakoune/blob/master/doc/pages/keys.asciidoc
# Meta (extension.ts & modes.ts)
# ================================================================================================
toggle:
title: Toggle
descr: Toggles Dance key bindings.
set.normal:
title: Set mode to Normal
descr: Set Dance mode to Normal.
keys: escape (insert)
set.insert:
title: Set mode to Insert
descr: Set Dance mode to Insert.
tmp.normal:
title: Temporary normal mode
descr: Switches to normal mode temporarily.
keys: c-v (insert)
tmp.insert:
title: Temporary insert mode
descr: Switches to insert mode temporarily.
keys: c-v (normal)
# Insert (insert.ts)
# ================================================================================================
insert.before:
title: Insert before
descr: Start insert before the current selections.
keys: i (normal)
insert.after:
title: Insert after
descr: Start insert after the current selections.
keys: a (normal)
insert.lineStart:
title: Insert at line start
descr: Start insert at line start of each selection.
keys: s-i (normal)
insert.lineEnd:
title: Insert at line end
descr: Start insert at line end of each selection.
keys: s-a (normal)
insert.newLine.below:
title: Insert new line below
descr: Create new line and start insert below.
keys: o (normal)
insert.newLine.above:
title: Insert new line above
descr: Create new line and start insert above.
keys: s-o (normal)
newLine.below:
title: Add new line below
descr: Add a new line below, without entering insert mode.
keys: a-o (normal)
newLine.above:
title: Add new line above
descr: Add a new line above, without entering insert mode.
keys: s-a-o (normal)
repeat.insert:
title: Repeat last insert-mode change
keys: . (normal)
repeat.objectOrSelectTo:
title: Repeat last object select / character find
keys: a-. (normal)
# Move around (move.ts)
# ================================================================================================
left:
title: Move left
keys: left (normal), h (normal)
add: extend
right:
title: Move right
keys: right (normal), l (normal)
add: extend
up:
title: Move up
keys: up (normal), k (normal)
add: extend
down:
title: Move down
keys: down (normal), j (normal)
add: extend
up.page:
title: Scroll one page up
keys: c-b (normal), c-b (insert)
down.page:
title: Scroll one page down
keys: c-f (normal), c-f (insert)
up.halfPage:
title: Scroll half a page up
keys: c-u (normal), c-u (insert)
down.halfPage:
title: Scroll half a page down
keys: c-d (normal), c-d (insert)
# Select to / until / buffer / line (move.ts)
# ================================================================================================
select.to.included:
title: Select to
descr: Select to the next character pressed, including it.
keys: f (normal)
add: extend back
select.to.excluded:
title: Select until
descr: Select until the next character pressed, excluding it.
keys: t (normal)
add: extend back
select.buffer:
title: Select whole buffer
keys: s-5 (normal)
select.line:
title: Select line
descr: Select line on which the end of each selection lies (or next line when end lies on an end-of-line).
keys: x (normal)
add: extend
select.toLineBegin:
title: Select to line beginning
keys: a-h (normal), home (normal)
add: extend
select.toLineEnd:
title: Select to line end
keys: a-l (normal), end (normal)
add: extend
select.enclosing:
title: Select enclosing characters
keys: m (normal)
add: extend back
expandLines:
title: Extend lines
descr: Extend selections to contain full lines (including end-of-lines).
keys: a-x (normal)
trimLines:
title: Trim lines
descr: Trim selections to only contain full lines (from start to line break).
keys: s-a-x (normal)
trimSelections:
title: Trim selections
descr: Trim whitespace at beginning and end of selections.
keys: s-- (normal)
# Select word (move.ts)
# ================================================================================================
select.word:
title: Select to next word start
descr: Select the word and following whitespaces on the right of the end of each selection.
keys: w (normal)
add: extend
select.word.previous:
title: Select to previous word start
descr: Select preceding whitespaces and the word on the left of the end of each selection.
keys: b (normal)
add: extend
select.word.end:
title: Select to next word end
descr: Select preceding whitespaces and the word on the right of the end of each selection.
keys: e (normal)
add: extend
select.word.alt:
title: Select to next non-whitespace word start
descr: Select the non-whitespace word and following whitespaces on the right of the end of each selection.
keys: a-w (normal)
add: extend
select.word.alt.previous:
title: Select to previous non-whitespace word start
descr: Select preceding whitespaces and the non-whitespace word on the left of the end of each selection.
keys: a-b (normal)
add: extend
select.word.alt.end:
title: Select to next non-whitespace word end
descr: Select preceding whitespaces and the non-whitespace word on the right of the end of each selection.
keys: a-e (normal)
add: extend
# Select within current selections (multiple.ts)
# ================================================================================================
select:
title: Select
descr: Select within current selections according to a RegExp.
keys: s (normal)
split:
title: Split
descr: Split within current selections according to a RegExp.
keys: s-s (normal)
split.lines:
title: Split lines
descr: Split selections into lines.
keys: a-s (normal)
select.firstLast:
title: Select first and last characters
descr: Select first and last characters of each selection.
keys: s-a-s (normal)
select.copy:
title: Copy selection to next line
keys: s-c (normal)
select.copy.backwards:
title: Copy selection to previous line
keys: s-a-c (normal)
# Switch current selections and modify selections (select.ts)
# ================================================================================================
selections.reduce:
title: Reduce selections
descr: Reduce selections to their cursor.
keys: ; (normal)
selections.flip:
title: Flip selections
descr: Flip the direction of each selection.
keys: a-; (normal)
selections.forward:
title: Forward selections
descr: Ensure selections are in forward direction (the active cursor is after the anchor).
keys: s-a-; (normal)
selections.backward:
title: Backward selections
descr: Ensure selections are in backward direction (the active cursor is before the anchor).
selections.clear:
title: Clear selections
descr: Clear selections (except main)
keys: space (normal)
selections.clearMain:
title: Clear main selection
keys: a-space (normal)
selections.keepMatching:
title: Keep matching selections
descr: Keep selections that match a RegExp.
keys: a-k (normal)
selections.clearMatching:
title: Clear matching selections
descr: Clear selections that match a RegExp.
keys: s-a-k (normal)
selections.merge:
title: Merge contiguous selections
descr: Merge contiguous selections together, including across lines.
keys: s-a-- (normal)
selections.align:
title: Align selections
descr: Align selections, aligning the cursor of each selection by inserting spaces before the first character of each selection.
keys: "s-7 (normal)"
selections.align.copy:
title: Copy indentation
descr: Copy the indentation of the main selection (or the count one if a count is given) to all other ones.
keys: "s-a-7 (normal)"
# Yank & Paste (yankPaste.ts)
# ================================================================================================
delete.yank:
title: Yank and delete
descr: Yank and delete selections.
keys: d (normal)
delete.insert.yank:
title: Yank, delete and insert
descr: Yank, delete and enter insert mode.
keys: c (normal)
delete.noYank:
title: Delete without yank
descr: Delete selections without yanking.
keys: a-d (normal)
delete.insert.noYank:
title: Delete and insert without yank
descr: Delete selections without yanking and enter insert mode.
keys: a-c (normal)
yank:
title: Yank
descr: Yank selections.
keys: y (normal)
paste.after:
title: Paste after
descr: Paste after the end of each selection.
keys: p (normal)
paste.before:
title: Paste before
descr: Paste before the start of each selection.
keys: s-p (normal)
paste.select.after:
title: Paste after and select
descr: Paste after the end of each selection and select pasted text.
keys: a-p (normal)
paste.select.before:
title: Paste before and select
descr: Paste before the start of each selection and select pasted text.
keys: s-a-p (normal)
paste.replace:
title: Replace
descr: Replace selections with yanked text.
keys: s-r (normal)
paste.replace.every:
title: Replace with every
descr: Replace selections with every yanked text.
keys: s-a-r (normal)
# Changes (changes.ts)
# ================================================================================================
replace.characters:
title: Replace character
descr: Replace each selected character with the next entered one.
keys: r (normal)
join:
title: Join lines
descr: Join selected lines.
keys: a-j (normal)
join.select:
title: Join lines and select spaces
descr: Join selected lines and select spaces inserted in place of line breaks.
keys: s-a-j (normal)
indent:
title: Indent
descr: Indent selected lines.
keys: "s-. (normal)"
indent.withEmpty:
title: Indent (including empty)
descr: Indent selected lines (including empty lines).
keys: "s-a-. (normal)"
deindent:
title: Deindent
descr: Deindent selected lines.
keys: "s-a-, (normal)"
deindent.further:
title: Deindent (including incomplete indent)
descr: Deindent selected lines (and remove additional incomplete indent).
keys: "s-, (normal)"
toLowerCase:
title: Transform to lowercase
keys: "` (normal)"
toUpperCase:
title: Transform to uppercase
keys: "s-` (normal)"
swapCase:
title: Swap case
keys: "a-` (normal)"
# Pipes (pipe.ts)
# ================================================================================================
pipe.filter:
title: Filter through pipe
descr: Pipe each selection to a program, and keeps it if the program returns 0.
keys: s-4 (normal)
pipe.replace:
title: Pipe and replace
descr: Pipe each selection to a command, and replaces it with its output.
keys: s-\ (normal)
pipe.ignore:
title: Pipe
descr: Pipe each selection to a command, ignoring their results.
keys: s-a-\ (normal)
pipe.append:
title: Pipe and append
descr: Pipe each selection to a command, appending the output after the selection.
keys: s-1 (normal)
pipe.prepend:
title: Pipe and prepend
descr: Pipe each selection to a command, prepending the output before the selection.
keys: s-a-1 (normal)
# History (history.ts)
# ================================================================================================
history.undo:
title: Undo
keys: u (normal)
history.backward:
title: Move backward in history
keys: a-u (normal)
history.redo:
title: Redo
keys: s-u (normal)
history.forward:
title: Move forward in history
keys: s-a-u (normal)
history.repeat:
title: Repeat last change
history.repeat.selection:
title: Repeat last selection change
history.repeat.edit:
title: Repeat last edit change
# Macros (macros.ts)
# ================================================================================================
macros.record.start:
title: Start recording macro
keys: s-q (normal -macro)
macros.record.stop:
title: Stop recording macro
keys: escape (normal)
# Note: this command executes even if a macro recording is not in progess,
# so that <esc> will noop instead of defaulting to deselecting in VSCode.
macros.play:
title: Play macro
keys: q (normal)
# Rotate (rotate.ts)
# ================================================================================================
rotate:
title: Rotate
descr: Rotate each selection clockwise.
keys: s-9 (normal)
rotate.backwards:
title: Rotate backwards
descr: Rotate each selection counter-clockwise.
keys: s-0 (normal)
rotate.content:
title: Rotate selection content
descr: Rotate each selection (as well as its content) clockwise.
keys: s-a-9 (normal)
rotate.content.backwards:
title: Rotate selection content backwards
descr: Rotate each selection (as well as its content) counter-clockwise.
keys: s-a-0 (normal)
rotate.contentOnly:
title: Rotate content only
descr: Rotate each selection content clockwise, without changing selections.
rotate.contentOnly.backwards:
title: Rotate content only backwards
descr: Rotate each selection content counter-clockwise, without changing selections.
# Search (search.ts)
# ================================================================================================
search:
title: Search
descr: Search for the given input string.
keys: / (normal)
add: extend
search.backwards:
title: Search backwards
descr: Search for the given input string before the current selections.
keys: a-/ (normal)
add: extend
search.selection.smart:
title: Search current selections (smart)
keys: s-8 (normal)
search.selection:
title: Search current selections
keys: s-a-8 (normal)
search.next:
title: Select next match
descr: Select next match after the main selection.
keys: n (normal)
search.next.add:
title: Add next match
descr: Add a new selection with the next match after the main selection.
keys: s-n (normal)
search.previous:
title: Select previous match
descr: Select previous match before the main selection.
keys: a-n (normal)
search.previous.add:
title: Add previous match
descr: Add a new selection with the previous match before the main selection.
keys: s-a-n (normal)
# Objects (selectObject.ts)
# ================================================================================================
_objects.select:
title: Select whole object
keys: a-a (normal), a-a (insert)
command: dance.openMenu
args: { menu: "object", action: "select" }
_objects.select.inner:
title: Select inner object
keys: a-i (normal), a-i (insert)
command: dance.openMenu
args: { menu: "object", action: "select", inner: true }
_objects.selectToStart:
title: Select to the whole object start
keys: "[ (normal)"
add: inner, extend
command: dance.openMenu
args: { menu: "object", action: "selectToStart" }
_objects.selectToEnd:
title: Select to the whole object end
keys: "] (normal)"
add: inner, extend
command: dance.openMenu
args: { menu: "object", action: "selectToEnd" }
objects.performSelection:
title: Perform selections specified in the arguments.
# Goto (goto.ts)
# ================================================================================================
goto:
title: Go to...
descr: Shows prompt to jump somewhere
keys: g (normal)
add: extend
goto.lineStart:
title: Go to line start
add: extend
goto.lineStart.nonBlank:
title: Go to non-blank line start
descr: Go to first non-whitespace character of the line
add: extend
goto.lineEnd:
title: Go to line end
add: extend
goto.firstLine:
title: Go to first line
add: extend
goto.lastLine:
title: Go to last line
add: extend
goto.lastCharacter:
title: Go to last character of the document
add: extend
goto.firstVisibleLine:
title: Go to first visible line
add: extend
goto.middleVisibleLine:
title: Go to middle visible line
add: extend
goto.lastVisibleLine:
title: Go to last visible line
add: extend
goto.selectedFile:
title: Open file under selection
goto.lastModification:
title: Go to last buffer modification position
add: extend
# Menus (menus.ts)
# ================================================================================================
openMenu:
title: Open quick-jump menu
# Registers and marks (mark.ts)
# ================================================================================================
registers.insert:
title: Insert value in register
keys: c-r (normal), c-r (insert)
registers.select:
title: Select register for next command
keys: s-\' (normal)
marks.saveSelections:
title: Save selections
keys: s-z (normal)
marks.restoreSelections:
title: Restore selections
keys: z (normal)
marks.combineSelections.fromCurrent:
title: Combine current selections with ones from register
keys: s-a-z (normal)
marks.combineSelections.fromRegister:
title: Combine register selections with current ones
keys: a-z (normal)
# Misc. (misc.ts)
cancel:
title: Cancel operation
descr: Cancels waiting for input from the user
keys: escape (awaiting)
run:
title: Run code
descr: Runs JavaScript code passed in a 'code' argument
_commandPalette:
title: Open Command Palette
descr: Open the built-in Command Palette in VSCode
keys: s-; (normal)
command: "workbench.action.showCommands"

View File

@ -1,269 +0,0 @@
// @ts-ignore
import { CORE_SCHEMA, load } from "js-yaml";
import { WriteStream, createWriteStream, readFileSync } from "fs";
// File setup
// ===============================================================================================
const prefix = "dance";
const header = "Auto-generated by commands/generate.ts. Do not edit manually.";
const stream: WriteStream = createWriteStream("./commands/index.ts", "utf8");
const doc: WriteStream = createWriteStream("./commands/README.md", "utf8");
stream.write(`// ${header}
/** A provided command. */
export interface ICommand<ID extends string = string> {
readonly id: ID;
readonly title: string;
readonly description: string;
readonly keybindings: {
readonly key: string;
readonly when: string;
}[];
}
`);
doc.write(`
Commands
========
<!-- ${header} -->
Commands are defined in [\`commands.yaml\`](./commands.yaml), and then exported
to VS Code-compatible [commands](https://code.visualstudio.com/api/extension-guides/command)
and [key bindings](https://code.visualstudio.com/docs/getstarted/keybindings).
They are implemented in [\`src/commands\`](../src/commands).
| ID | Title | Description | Key bindings |
| -- | ----- | ----------- | ------------ |
`);
// Data setup
// ===============================================================================================
interface Entry {
title: string;
descr: string;
keys?: string;
add?: string;
command?: string;
args?: any;
}
const yaml: Record<string, Entry> = load(readFileSync("./commands/commands.yaml", "utf8"), {
schema: CORE_SCHEMA,
});
const prefixKeys = (prefix: string, keys?: string) => keys?.replace(/(^|, )(.)/g, `$1${prefix}$2`);
for (const id in yaml) {
const command = yaml[id];
if (command.descr == null) {
command.descr = command.title + ".";
}
if (command.add && command.add.includes("extend")) {
let title = command.title.replace("Select to", "Extend to").replace("Select", "Extend with");
let descr = command.descr.replace("Select to", "Extend to").replace("Select", "Extend with");
if (title === "Extend with until") {
title = "Extend until";
}
if (title === command.title) {
title += " (extend)";
}
if (descr === command.descr) {
descr = descr.replace(".", " (extend).");
}
yaml[id + ".extend"] = {
...command,
title,
descr,
keys: prefixKeys("s-", command.keys),
args: command.args ? { ...command.args, extend: true } : undefined,
};
}
}
for (const id in yaml) {
const command = yaml[id];
if (command.add && command.add.includes("back")) {
yaml[id + ".backwards"] = {
...command,
title: `${command.title} (backwards)`,
descr: `${command.descr} (backwards)`,
keys: prefixKeys("a-", command.keys!),
args: command.args ? { ...command.args, backwards: true } : undefined,
};
}
}
for (const id in yaml) {
const command = yaml[id];
if (command.add && command.add.includes("inner")) {
yaml[id + ".inner"] = {
...command,
title: command.title.replace("whole", "inner"),
descr: command.descr.replace("whole", "inner"),
keys: prefixKeys("a-", command.keys!),
args: command.args ? { ...command.args, inner: true } : undefined,
};
}
}
for (let i = 0; i < 10; i++) {
yaml[`count.${i}`] = {
title: `Count ${i}`,
descr: `Adds ${i} to the current counter for the next operation.`,
keys: `${i} (normal)`,
};
}
// Generate TypeScript and Markdown files
// ===============================================================================================
const commands: string[] = [];
const matches = (regex: RegExp, input: string) => {
const m: RegExpExecArray[] = [];
let match: RegExpExecArray | null;
while ((match = regex.exec(input))) {
m.push(match);
}
return m;
};
const parseWhen = (when: string) =>
(({
"enabled": `${prefix}.mode != 'disabled'`,
"normal": `${prefix}.mode == 'normal'`,
"insert": `${prefix}.mode == 'insert'`,
"awaiting": `${prefix}.mode == 'awaiting'`,
"normal macro": `${prefix}.mode == 'normal' && ${prefix}.recordingMacro`,
"normal -macro": `${prefix}.mode == 'normal' && !${prefix}.recordingMacro`,
} as any)[when]);
const parseKey = (key: string) =>
key.replace("a-", "Alt+").replace("s-", "Shift+").replace("c-", "Ctrl+");
const writable = (id: string) => id.replace(/\.\w/g, (c) => c.substr(1).toUpperCase());
const parseKeys = (key: string) =>
matches(/([\S]+) \(([\w- ]+)\)/g, key).map((x) => ({
key: parseKey(x[1]),
when: parseWhen(x[2]),
}));
const additionalKeyBindings: string[] = [];
for (const id in yaml) {
const command = yaml[id];
const keys = parseKeys(command.keys || "");
const docKeys = keys
.map(
({ key, when }) => `\`${key.replace(/(\+|^)[a-z]/g, (x) => x.toUpperCase())}\` (\`${when}\`)`,
)
.join(", ");
const docStringKeys = docKeys.length === 0
? ""
: `\n *\n * Default key${keys.length === 1 ? "" : "s"}: ${docKeys}.`;
const docCommandName = command.command ? "" : `\`${prefix}.${id}\``;
doc.write(`| ${docCommandName} | ${command.title} | ${command.descr} | ${docKeys} |\n`);
if (command.command) {
// This is an additional keybinding to an existing command, instead of a
// new command to be implemented.
for (const key of keys) {
additionalKeyBindings.push(` {`);
additionalKeyBindings.push(
` key : "${key.key.replace("\\", "\\\\").replace('"', '\\"')}",`,
);
additionalKeyBindings.push(
` when : "editorTextFocus && ${key.when.replace(/"/g, '\\"')}",`,
);
additionalKeyBindings.push(` command: ${JSON.stringify(command.command)},`);
if (command.args !== undefined) {
const args = JSON.stringify(command.args, undefined, " ")
.replace(/\n/g, "")
.replace(/}$/, " }");
additionalKeyBindings.push(` args : ${args},`);
}
additionalKeyBindings.push(` },`);
}
continue;
}
commands.push(id);
stream.write(`/**\n * ${command.descr}${docStringKeys}\n */\n`);
stream.write(`export const ${writable(id)}: ICommand<"${prefix}.${id}"> = {\n`);
stream.write(` id : "${prefix}.${id}",\n`);
stream.write(` title : "${command.title}",\n`);
stream.write(` description: "${command.descr.replace('"', '\\"')}",\n`);
if (command.keys) {
stream.write(` keybindings: [\n`);
for (const key of keys) {
stream.write(
` { key: "${key.key
.replace("\\", "\\\\")
.replace('"', '\\"')}", when: "editorTextFocus && ${key.when.replace(/"/g, '\\"')}" },\n`,
);
}
stream.write(` ],\n`);
} else {
stream.write(" keybindings: [],\n");
}
stream.write(`};\n\n`);
}
// Write footers and close streams
// ===============================================================================================
stream.write(`
/**
* All defined commands.
*/
export const commands = {
${commands.map((x) => ` /** ${yaml[x].descr} */\n ${writable(x)}`).join(",\n")},
};
/**
* An enum which maps command names to command IDs.
*/
export const enum Command {
${commands
.map((x) => ` /** ${yaml[x].descr} */\n ${writable(x)} = "${prefix}.${x}"`)
.join(",\n")},
}
/**
* Additional key bindings.
*/
export const additionalKeyBindings = [
${additionalKeyBindings.join("\n")}
];
`);
doc.write("\n");
stream.end();
doc.end();

File diff suppressed because it is too large Load Diff

692
meta.ts Normal file
View File

@ -0,0 +1,692 @@
import * as assert from "assert";
import * as fs from "fs/promises";
import * as G from "glob";
import * as path from "path";
const verbose = process.argv.includes("--verbose");
const moduleCommentRe =
new RegExp(String.raw`\/\*\*\n` // start of doc comment
+ String.raw`((?: \*(?:\n| .+\n))+?)` // #1: doc comment
+ String.raw` \*\/\n` // end of doc comment
+ String.raw`declare module \"(.+?)\"`, // #2: module name
"m");
const docCommentRe =
new RegExp(String.raw`^( *)` // #1: indentation
+ String.raw`\/\*\*\n` // start of doc comment
+ String.raw`((?:\1 \*(?:\n| .+\n))+?)` // #2: doc comment
+ String.raw`\1 \*\/\n` // end of doc comment
+ String.raw`\1export (?:async )?function (\w+)` // #3: function name
+ String.raw`\((.*|\n[\s\S]+?^\1)\)` // #4: parameters
+ String.raw`(?:: )?(.+)[;{]$` // #5: return type (optional)
+ "|" // or
+ String.raw`^ *export namespace (\w+) {\n` // #6: namespace (alternative)
+ String.raw`^( +)`, // #7: namespace indentation
"gm");
function countNewLines(text: string) {
let count = 0;
for (let i = 0; i < text.length; i++) {
if (text.charCodeAt(i) === 10 /* \n */) {
count++;
}
}
return count;
}
const keyMapping: Record<string, keyof Builder.AdditionalCommand> = {
Command: "commands",
Commands: "commands",
Identifier: "identifier",
Identifiers: "identifier",
Keys: "keys",
Keybinding: "keys",
Keybindings: "keys",
Title: "title",
};
const valueConverter: Record<keyof Builder.AdditionalCommand, (x: string) => string> = {
commands(commands) {
return commands
.replace(/^`+|`+$/g, "")
.replace(/MAX_INT/g, `${2 ** 31 - 1}`); // Max integer supported in JSON.
},
identifier(identifier) {
return identifier.replace(/^`+|`+$/g, "");
},
keys(keys) {
return keys;
},
title(title) {
return title;
},
qualifiedIdentifier(qualifiedIdentifier) {
return qualifiedIdentifier;
},
line() {
throw new Error("this should not be called");
},
};
function parseAdditional(qualificationPrefix: string, text: string, textStartLine: number) {
const lines = text.split("\n"),
additional: Builder.AdditionalCommand[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.length > 2 && line.startsWith("| ") && line.endsWith(" |")) {
const keys = line
.slice(2, line.length - 2) // Remove start and end |.
.split(" | ") // Split into keys.
.map((k) => keyMapping[k.trim()]); // Normalize keys.
i++;
if (/^\|[-| ]+\|$/.test(lines[i])) {
i++;
}
while (i < lines.length) {
const line = lines[i];
if (!line.startsWith("| ") || !line.endsWith(" |")) {
break;
}
i++;
const obj: Builder.AdditionalCommand = { line: textStartLine + i },
values = line.slice(2, line.length - 2).split(" | ");
for (let j = 0; j < values.length; j++) {
const key = keys[j],
value = valueConverter[key](values[j].trim());
(obj as Record<string, any>)[key] = value;
}
if ("identifier" in obj) {
obj.qualifiedIdentifier = qualificationPrefix + obj.identifier;
}
additional.push(obj);
}
}
}
return additional;
}
/**
* Parses all the doc comments of functions in the given string of TypeScript
* code. Examples will be parsed using the given function.
*/
function parseDocComments(code: string, modulePath: string) {
let moduleDoc: string,
moduleDocStartLine: number,
moduleName: string;
const moduleHeaderMatch = moduleCommentRe.exec(code);
if (moduleHeaderMatch !== null) {
moduleDoc = moduleHeaderMatch[1].split("\n").map((line) => line.slice(3)).join("\n");
moduleDocStartLine = code.slice(0, moduleHeaderMatch.index).split("\n").length + 2;
moduleName = moduleHeaderMatch[2].replace(/^\.\//, "");
} else {
moduleDoc = "";
moduleDocStartLine = 0;
moduleName = path.basename(modulePath, ".ts");
}
if (verbose) {
console.log("Parsing doc comments in module", moduleName);
}
const modulePrefix = moduleName === "misc" ? "" : moduleName + ".";
const functions: Builder.ParsedFunction[] = [],
namespaces: string[] = [];
let previousIndentation = 0;
for (let match = docCommentRe.exec(code); match !== null; match = docCommentRe.exec(code)) {
const indentationString = match[1],
docCommentString = match[2],
functionName = match[3],
parametersString = match[4],
returnTypeString = match[5],
enteredNamespace = match[6],
enteredNamespaceIndentation = match[7],
startLine = countNewLines(code.slice(0, match.index)),
endLine = startLine + countNewLines(match[0]);
if (enteredNamespace !== undefined) {
namespaces.push(enteredNamespace);
previousIndentation = enteredNamespaceIndentation.length;
continue;
}
const indentation = indentationString.length,
namespace = namespaces.length === 0 ? undefined : namespaces.join("."),
returnType = returnTypeString.trim(),
parameters = parametersString
.split(/,(?![^:]+?[}>])/g)
.map((p) => p.trim())
.filter((p) => p.length > 0)
.map((p) => {
let match: RegExpExecArray | null;
if (match = /^(\w+\??|.+[}\]]): *(.+)$/.exec(p)) {
return match.slice(1) as [string, string];
}
if (match = /^(\w+) *= *(\d+|true|false)$/.exec(p)) {
const type = match[2] === "true" || match[2] === "false"
? "Argument<boolean>"
: "number";
return [match[1], `${type} = ${match[2]}`] as [string, string];
}
if (match = /^(\w+) *= *(\w+)\.([\w.]+)$/.exec(p)) {
return [match[1], `${match[2]} = ${match[2]}.${match[3]}`] as [string, string];
}
if (match = /^(\.\.\.\w+): *(.+)$/.exec(p)) {
return [match[1], match[2]] as [string, string];
}
throw new Error(`unrecognized parameter pattern ${p}`);
}),
docComment = docCommentString
.split("\n")
.map((line) => line.slice(indentation).replace(/^ \* ?/g, ""))
.join("\n");
if (previousIndentation > indentation) {
namespaces.pop();
previousIndentation = indentation;
}
for (const parameter of parameters) {
if (parameter[0].endsWith("?")) {
// Optional parameters.
parameter[0] = parameter[0].slice(0, parameter[0].length - 1);
parameter[1] += " | undefined";
} else {
const match = /^(.+?)\s+=\s+(.+)$/.exec(parameter[1]);
if (match !== null) {
// Optional parameters with default values.
parameter[1] = match[1] + " | undefined";
}
}
}
const splitDocComment = docComment.split(/\n### Example\n/gm),
properties: Record<string, string> = {},
doc = splitDocComment[0].replace(/^@(param \w+|\w+)(?:\n| ((?:.+\n)(?: {2}.+\n)*))/gm,
(_, k: string, v: string) => {
properties[k] = v?.replace(/\n {2}/g, " ").trim();
return "";
}),
summary = /((?:.+(?:\n|$))+)/.exec(doc)![0].trim().replace(/\.$/, ""),
examplesStrings = splitDocComment.slice(1),
nameWithDot = functionName.replace(/_/g, ".");
let qualifiedName = modulePrefix;
if (namespace !== undefined) {
qualifiedName += namespace + ".";
}
if (nameWithDot === moduleName) {
qualifiedName = qualifiedName.replace(/\.$/, "");
} else {
qualifiedName += nameWithDot;
}
functions.push({
namespace,
name: functionName,
nameWithDot,
qualifiedName,
startLine,
endLine,
doc,
properties,
summary,
examples: examplesStrings,
additional: parseAdditional(modulePrefix, splitDocComment[0], startLine),
parameters,
returnType: returnType.length === 0 ? undefined : returnType,
});
}
docCommentRe.lastIndex = 0;
return {
path: path.relative(path.dirname(__dirname), modulePath).replace(/\\/g, "/"),
name: moduleName,
doc: moduleDoc,
additional: parseAdditional(modulePrefix, moduleDoc, moduleDocStartLine),
functions,
functionNames: [...new Set(functions.map((f) => f.name))],
get commands() {
return getCommands(this);
},
get keybindings() {
return getKeybindings(this);
},
} as Builder.ParsedModule;
}
/**
* Mapping from character to corresponding VS Code keybinding.
*/
export const specialCharacterMapping = {
"~": "s-`",
"!": "s-1",
"@": "s-2",
"#": "s-3",
"$": "s-4",
"%": "s-5",
"^": "s-6",
"&": "s-7",
"*": "s-8",
"(": "s-9",
")": "s-0",
"_": "s--",
"+": "s-=",
"{": "s-[",
"}": "s-]",
"|": "s-\\",
":": "s-;",
'"': "s-'",
"<": "s-,",
">": "s-.",
"?": "s-/",
};
/**
* RegExp for keys of `specialCharacterMapping`.
*/
export const specialCharacterRegExp = /[~!@#$%^&*()_+{}|:"<>?]/g;
/**
* Async wrapper around the `glob` package.
*/
export function glob(pattern: string, ignore?: string) {
return new Promise<string[]>((resolve, reject) => {
G(pattern, { ignore }, (err, matches) => err ? reject(err) : resolve(matches));
});
}
/**
* A class used in .build.ts files.
*/
export class Builder {
private _apiModules?: Builder.ParsedModule[];
private _commandModules?: Builder.ParsedModule[];
/**
* Returns all modules for API files.
*/
public async getApiModules() {
if (this._apiModules !== undefined) {
return this._apiModules;
}
const apiFiles = await glob(`${__dirname}/src/api/**/*.ts`, /* ignore= */ "**/*.build.ts"),
apiModules = await Promise.all(
apiFiles.map((filepath) =>
fs.readFile(filepath, "utf-8").then((code) => parseDocComments(code, filepath))));
return this._apiModules = apiModules.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* Returns all modules for command files.
*/
public async getCommandModules() {
if (this._commandModules !== undefined) {
return this._commandModules;
}
const commandsGlob = `${__dirname}/src/commands/**/*.ts`,
commandFiles = await glob(commandsGlob, /* ignore= */ "**/*.build.ts"),
allCommandModules = await Promise.all(
commandFiles.map((filepath) =>
fs.readFile(filepath, "utf-8").then((code) => parseDocComments(code, filepath)))),
commandModules = allCommandModules.filter((m) => m.doc.length > 0);
return this._commandModules = commandModules.sort((a, b) => a.name.localeCompare(b.name));
}
}
export namespace Builder {
export interface ParsedFunction {
readonly namespace?: string;
readonly name: string;
readonly nameWithDot: string;
readonly qualifiedName: string;
readonly startLine: number;
readonly endLine: number;
readonly doc: string;
readonly properties: Record<string, string>;
readonly summary: string;
readonly examples: string[];
readonly additional: AdditionalCommand[];
readonly parameters: readonly [name: string, type: string][];
readonly returnType: string | undefined;
}
export interface AdditionalCommand {
title?: string;
identifier?: string;
qualifiedIdentifier?: string;
keys?: string;
commands?: string;
line: number;
}
export interface ParsedModule {
readonly path: string;
readonly name: string;
readonly doc: string;
readonly additional: readonly AdditionalCommand[];
readonly functions: readonly ParsedFunction[];
readonly functionNames: readonly string[];
readonly commands: {
readonly id: string;
readonly title: string;
readonly when?: string;
}[];
readonly keybindings: {
readonly title?: string;
readonly key: string;
readonly when: string;
readonly command: string;
readonly args?: any;
}[];
}
}
/**
* Parses the short "`s-a-b` (mode)"-like syntax for defining keybindings into
* a format compatible with VS Code keybindings.
*/
export function parseKeys(keys: string) {
if (keys.length === 0) {
return [];
}
return keys.split(/ *, (?=`)/g).map((keyString) => {
const match = /^(`+)(.+?)\1 \((.+?)\)$/.exec(keyString)!,
keybinding = match[2].trim().replace(
specialCharacterRegExp, (m) => (specialCharacterMapping as Record<string, string>)[m]);
// Reorder to match Ctrl+Shift+Alt+_
let key = "";
if (keybinding.includes("c-")) {
key += "Ctrl+";
}
if (keybinding.includes("s-")) {
key += "Shift+";
}
if (keybinding.includes("a-")) {
key += "Alt+";
}
const remainingKeybinding = keybinding.replace(/[csa]-/g, ""),
whenClauses = ["editorTextFocus"];
for (const tag of match[3].split(", ")) {
switch (tag) {
case "normal":
case "insert":
case "input":
whenClauses.push(`dance.mode == '${tag}'`);
break;
case "recording":
whenClauses.push("dance.isRecording");
break;
default:
throw new Error("unknown keybinding tag " + tag);
}
}
key += remainingKeybinding[0].toUpperCase() + remainingKeybinding.slice(1);
return {
key,
when: whenClauses.join(" && "),
};
});
}
/**
* Returns all defined commands in the given module.
*/
function getCommands(module: Omit<Builder.ParsedModule, "commands">) {
// TODO: improve conditions
return [
...module.functions.map((f) => ({
id: `dance.${f.qualifiedName}`,
title: f.summary,
when: "dance.mode == 'normal'",
})),
...module.additional
.concat(...module.functions.flatMap((f) => f.additional))
.filter((a) => a.identifier !== undefined && a.title !== undefined)
.map((a) => ({
id: `dance.${a.qualifiedIdentifier}`,
title: a.title!,
when: "dance.mode == 'normal'",
})),
].sort((a, b) => a.id.localeCompare(b.id));
}
/**
* Returns all defined keybindings in the given module.
*/
function getKeybindings(module: Omit<Builder.ParsedModule, "keybindings">) {
return [
...module.functions.flatMap((f) => parseKeys(f.properties.keys ?? "").map((key) => ({
...key,
title: f.summary,
command: `dance.${f.qualifiedName}`,
}))),
...module.additional
.concat(...module.functions.flatMap((f) => f.additional))
.flatMap(({ title, keys, commands, qualifiedIdentifier }) => {
const parsedKeys = parseKeys(keys ?? "");
if (qualifiedIdentifier !== undefined) {
return parsedKeys.map((key) => ({
...key,
title,
command: `dance.${qualifiedIdentifier}`,
}));
}
const parsedCommands =
JSON.parse("[" + commands!.replace(/(\w+):/g, "\"$1\":") + "]") as any[];
if (parsedCommands.length === 1) {
let [command]: [string] = parsedCommands[0];
if (command[0] === ".") {
command = "dance" + command;
}
return parsedKeys.map((key) => ({
...key,
title,
command,
args: parsedCommands[0][1],
}));
}
return parsedKeys.map((key) => ({
...key,
title,
command: "dance.run",
args: {
commands: parsedCommands,
},
}));
}),
].sort((a, b) => a.command.localeCompare(b.command));
}
/**
* Given a multiline string, returns the same string with all lines starting
* with an indentation `>= by` reduced by `by` spaces.
*/
export function unindent(by: number, string: string) {
return string.replace(new RegExp(`^ {${by}}`, "gm"), "").replace(/^ +$/gm, "");
}
/**
* Updates a .build.ts file.
*/
async function buildFile(fileName: string, builder: Builder) {
const relativeName = path.relative(__dirname, fileName),
relativeNameWithoutBuild = relativeName.replace(/build\.ts$/, ""),
modulePath = `./${relativeNameWithoutBuild}build`,
module: { build(builder: Builder): Promise<string> } = require(modulePath),
generatedContent = await module.build(builder);
if (typeof generatedContent === "string") {
const prefix = path.basename(relativeNameWithoutBuild),
outputName = (await fs.readdir(path.dirname(fileName)))
.find((path) => path.startsWith(prefix) && !path.endsWith(".build.ts"))!,
outputPath = path.join(path.dirname(fileName), outputName),
outputContent = await fs.readFile(outputPath, "utf-8"),
outputContentHeader =
/^[\s\S]+?\n.+Content below this line was auto-generated.+\n/m.exec(outputContent)![0];
await fs.writeFile(outputPath, outputContentHeader + generatedContent, "utf-8");
}
}
/**
* The main entry point of the script.
*/
async function main() {
let success = true;
const ensureUpToDate = process.argv.includes("--ensure-up-to-date"),
check = process.argv.includes("--check"),
buildIndex = process.argv.indexOf("--build"),
build = buildIndex === -1 ? "**/*.build.ts" : process.argv[buildIndex + 1];
const contentsBefore: string[] = [],
fileNames = [
`${__dirname}/package.json`,
`${__dirname}/src/commands/README.md`,
`${__dirname}/src/commands/index.ts`,
];
if (ensureUpToDate) {
contentsBefore.push(...await Promise.all(fileNames.map((name) => fs.readFile(name, "utf-8"))));
}
const builder = new Builder(),
filesToBuild = await glob(__dirname + "/" + build),
buildErrors: unknown[] = [];
await Promise.all(
filesToBuild.map((path) => buildFile(path, builder).catch((e) => buildErrors.push(e))));
if (buildErrors.length > 0) {
console.error(buildErrors);
}
if (ensureUpToDate) {
const contentsAfter = await Promise.all(fileNames.map((name) => fs.readFile(name, "utf-8")));
for (let i = 0; i < fileNames.length; i++) {
if (verbose) {
console.log("Checking file", fileNames[i], "for diffs...");
}
// The built-in "assert" module displays a multiline diff if the strings
// are different, so we use it instead of comparing manually.
assert.strictEqual(contentsBefore[i], contentsAfter[i]);
}
}
if (check) {
const filesToCheck = await glob(
`${__dirname}/src/commands/**/*.ts`, /* ignore= */ "**/*.build.ts"),
contentsToCheck = await Promise.all(filesToCheck.map((f) => fs.readFile(f, "utf-8")));
for (let i = 0; i < filesToCheck.length; i++) {
const fileToCheck = filesToCheck[i],
contentToCheck = contentsToCheck[i];
if (contentToCheck.includes("editor.selections")) {
console.error("File", fileToCheck, "includes forbidden access to editor.selections.");
success = false;
}
}
}
return success;
}
if (require.main === module) {
main().then((success) => {
if (!process.argv.includes("--watch")) {
process.exit(success ? 0 : 1);
}
import("chokidar").then((chokidar) => {
const watcher = chokidar.watch([
"**/*.build.ts",
"src/api/*.ts",
"src/commands/*.ts",
"test/suite/commands/*.md",
], {
ignored: "src/commands/load-all.ts",
});
let isGenerating = false;
watcher.on("change", async (path) => {
if (isGenerating) {
return;
}
console.log("Change detected at " + path + ", updating generated files...");
isGenerating = true;
try {
await main();
} finally {
isGenerating = false;
}
});
});
});
}

651
package.build.ts Normal file
View File

@ -0,0 +1,651 @@
import type { Builder } from "./meta";
// Shared values
// ============================================================================
const commandType = {
type: "array",
items: {
type: ["array", "object", "string"],
properties: {
command: {
type: "string",
},
args: {},
},
required: ["command"],
},
};
const builtinModesAreDeprecatedMessage =
"Built-in modes are deprecated. Use `#dance.modes#` instead.";
const modeNamePattern = {
pattern: /^[a-zA-Z]\w*$/.source,
patternErrorMessage: "",
};
const colorPattern = {
pattern: /^(#[a-fA-F0-9]{3}|#[a-fA-F0-9]{6}|#[a-fA-F0-9]{8}|\$([a-zA-Z]+(\.[a-zA-Z]+)+))$/.source,
patternErrorMessage: "Color should be an hex color or a '$' sign followed by a color identifier.",
};
const selectionDecorationType = {
type: "object",
properties: {
applyTo: {
enum: ["all", "main", "secondary"],
default: "all",
description: "The selections to apply this style to.",
enumDescriptions: [
"Apply to all selections.",
"Apply to main selection only.",
"Apply to all selections except main selection.",
],
},
backgroundColor: {
type: "string",
...colorPattern,
},
borderColor: {
type: "string",
...colorPattern,
},
borderStyle: {
type: "string",
},
borderWidth: {
type: "string",
},
borderRadius: {
type: "string",
},
isWholeLine: {
type: "boolean",
default: false,
},
},
};
// Package information
// ============================================================================
export const pkg = (modules: Builder.ParsedModule[]) => ({
// Common package.json properties.
// ==========================================================================
name: "dance",
description: "Make those cursors dance with Kakoune-inspired keybindings.",
version: "0.5.0-rc",
license: "ISC",
author: {
name: "Grégoire Geis",
email: "opensource@gregoirege.is",
},
repository: {
type: "git",
url: "https://github.com/71/dance.git",
},
main: "./out/src/extension.js",
engines: {
vscode: "^1.44.0",
},
scripts: {
"check": "eslint .",
"format": "eslint . --fix",
"generate": "ts-node ./meta.ts",
"generate:watch": "ts-node ./meta.ts --watch",
"vscode:prepublish": "yarn run generate && yarn run compile",
"compile": "tsc -p ./",
"compile:watch": "tsc -watch -p ./",
"test": "yarn run compile && node ./out/test/run.js",
"package": "vsce package",
"publish": "vsce publish",
},
devDependencies: {
"@types/glob": "^7.1.1",
"@types/mocha": "^8.0.3",
"@types/node": "^14.6.0",
"@types/vscode": "^1.44.0",
"@typescript-eslint/eslint-plugin": "^4.18.0",
"@typescript-eslint/parser": "^4.18.0",
"chokidar": "^3.5.1",
"eslint": "^7.22.0",
"glob": "^7.1.6",
"mocha": "^8.1.1",
"source-map-support": "^0.5.19",
"ts-node": "^9.1.1",
"typescript": "^4.2.4",
"unexpected": "^12.0.0",
"vsce": "^1.87.0",
"vscode-test": "^1.5.2",
},
// VS Code-specific properties.
// ==========================================================================
displayName: "Dance",
publisher: "gregoire",
categories: ["Keymaps", "Other"],
readme: "README.md",
icon: "assets/dance.png",
activationEvents: ["*"],
extensionKind: ["ui", "workspace"],
// Dance-specific properties.
// ==========================================================================
// The two properties below can be set when distributing Dance to ensure it
// cannot execute arbitrary code (with `dance.run`) or system commands (with
// `dance.selections.{filter,pipe}`).
"dance.disableArbitraryCodeExecution": false,
"dance.disableArbitraryCommandExecution": false,
contributes: {
// Configuration.
// ========================================================================
configuration: {
type: "object",
title: "Dance",
properties: {
"dance.defaultMode": {
type: "string",
scope: "language-overridable",
default: "normal",
description: "Controls which mode is set by default when an editor is opened.",
...modeNamePattern,
},
"dance.modes": {
type: "object",
scope: "language-overridable",
additionalProperties: {
type: "object",
propertyNames: modeNamePattern,
properties: {
inheritFrom: {
type: ["string", "null"],
description:
"Controls how default configuration options are obtained for this mode. "
+ "Specify a string to inherit from the mode with the given name, "
+ "and null to inherit from the VS Code configuration.",
...modeNamePattern,
},
cursorStyle: {
enum: [
"line",
"block",
"underline",
"line-thin",
"block-outline",
"underline-thin",
"inherit",
null,
],
description: "Controls the cursor style.",
},
lineHighlight: {
type: ["string", "null"],
markdownDescription:
"Controls the line highlighting applied to active lines. "
+ "Can be an hex color, a [theme color]("
+ "https://code.visualstudio.com/api/references/theme-color) or null.",
...colorPattern,
},
lineNumbers: {
enum: ["off", "on", "relative", "inherit", null],
description: "Controls the display of line numbers.",
enumDescriptions: [
"No line numbers.",
"Absolute line numbers.",
"Relative line numbers.",
"Inherit from `editor.lineNumbers`.",
],
},
onEnterMode: {
...commandType,
description:
"Controls what commands should be executed upon entering this mode.",
},
onLeaveMode: {
...commandType,
description:
"Controls what commands should be executed upon leaving this mode.",
},
selectionBehavior: {
enum: ["caret", "character", null],
default: "caret",
description: "Controls how selections behave within VS Code.",
markdownEnumDescriptions: [
"Selections are anchored to carets, which is the native VS Code behavior; "
+ "that is, they are positioned *between* characters and can therefore be "
+ "empty.",
"Selections are anchored to characters, like Kakoune; that is, they are "
+ "positioned *on* characters, and therefore cannot be empty. "
+ "Additionally, one-character selections will behave as if they were "
+ "non-directional, like Kakoune.",
],
},
decorations: {
...selectionDecorationType,
type: ["array", "object", "null"],
description: "The decorations to apply to selections.",
items: selectionDecorationType,
},
},
additionalProperties: false,
},
default: {
insert: {},
normal: {
lineNumbers: "relative",
decorations: {
applyTo: "main",
backgroundColor: "$editor.hoverHighlightBackground",
isWholeLine: true,
},
onEnterMode: [
[".selections.restore", { register: " ^", try: true }],
],
onLeaveMode: [
[".selections.save", {
register: " ^",
style: {
borderColor: "$editor.selectionBackground",
borderStyle: "solid",
borderWidth: "2px",
borderRadius: "1px",
},
until: [
["mode-did-change", { include: "normal" }],
["selections-did-change"],
],
}],
],
},
},
description: "Controls the different modes available in Dance.",
},
"dance.menus": {
type: "object",
scope: "language-overridable",
description: "Controls the different menus available in Dance.",
additionalProperties: {
type: "object",
properties: {
items: {
type: "object",
additionalProperties: {
type: "object",
properties: {
text: {
type: "string",
description: "Text shown in the menu.",
},
command: {
type: "string",
description: "Command to execute on item selection.",
},
args: {
type: "array",
description: "Arguments to the command to execute.",
},
},
required: ["command"],
},
},
},
additionalProperties: false,
},
default: {
"object": {
items: ((command = "dance.seek.object") => ({
"b()": {
command,
args: [{ input: "\\((?#inner)\\)" }],
text: "parenthesis block",
},
"B{}": {
command,
args: [{ input: "\\{(?#inner)\\}" }],
text: "braces block",
},
"r[]": {
command,
args: [{ input: "\\[(?#inner)\\]" }],
text: "brackets block",
},
"a<>": {
command,
args: [{ input: "<(?#inner)>" }],
text: "angle block",
},
'Q"': {
command,
args: [{ input: "(?#noescape)\"(?#inner)(?#noescape)\"" }],
text: "double quote string",
},
"q'": {
command,
args: [{ input: "(?#noescape)'(?#inner)(?#noescape)'" }],
text: "single quote string",
},
"g`": {
command,
args: [{ input: "(?#noescape)`(?#inner)(?#noescape)`" }],
text: "grave quote string",
},
"w": {
command,
args: [{ input: "[\\p{L}]+(?<after>[^\\S\\n]+)" }],
text: "word",
},
"W": {
command,
args: [{ input: "[\\S]+(?<after>[^\\S\\n]+)" }],
text: "WORD",
},
"s": {
command,
args: [{ input: "(?#predefined=sentence)" }],
text: "sentence",
},
"p": {
command,
args: [{ input: "(?#predefined=paragraph)" }],
text: "paragraph",
},
" ": {
command,
args: [{ input: "(?<before>[\\s]+)[^\\S\\n]+(?<after>[\\s]+)" }],
text: "whitespaces",
},
"i": {
command,
args: [{ input: "(?#predefined=indent)" }],
text: "indent",
},
"n": {
command,
args: [{ input: "(?#singleline)-?[\\d_]+(\\.[0-9]+)?([eE]\\d+)?" }],
text: "number",
},
"u": {
command,
args: [{ input: "(?#predefined=argument)" }],
text: "argument",
},
"c": {
command,
text: "custom object desc",
},
}))(),
},
"goto": {
items: {
"h": {
text: "to line start",
command: "dance.select.lineStart",
},
"l": {
text: "to line end",
command: "dance.select.lineEnd",
},
"i": {
text: "to non-blank line start",
command: "dance.select.lineStart",
args: [{ skipBlank: true }],
},
"gk": {
text: "to first line",
command: "dance.select.lineStart",
args: [{ count: 0 }],
},
"j": {
text: "to last line",
command: "dance.select.lastLine",
},
"e": {
text: "to last char of last line",
command: "dance.select.lineEnd",
args: [{ count: 2 ** 31 - 1 }],
},
"t": {
text: "to first displayed line",
command: "dance.select.firstVisibleLine",
},
"c": {
text: "to middle displayed line",
command: "dance.select.middleVisibleLine",
},
"b": {
text: "to last displayed line",
command: "dance.select.lastVisibleLine",
},
"f": {
text: "to file whose name is selected",
command: "dance.selections.open",
},
".": {
text: "to last buffer modification position",
command: "dance.select.lastModification",
},
},
},
"view": {
items: {
// AFAIK, we can't implement these yet since VS Code only
// exposes vertical view ranges:
// - m, center cursor horizontally
// - h, scroll left
// - l, scroll right
"vc": {
text: "center cursor vertically",
command: "dance.view.line",
args: [{ at: "center" }],
},
"t": {
text: "cursor on top",
command: "dance.view.line",
args: [{ at: "top" }],
},
"b": {
text: "cursor on bottom",
command: "dance.view.line",
args: [{ at: "bottom" }],
},
"j": {
text: "scroll down",
command: "editorScroll",
args: [{ to: "down", by: "line", revealCursor: true }],
},
"k": {
text: "scroll up",
command: "editorScroll",
args: [{ to: "up", by: "line", revealCursor: true }],
},
},
},
} as Record<string,
{ items: Record<string, { text: string; command: string; args?: any[] }>}>,
},
// Deprecated configuration:
"dance.enabled": {
type: "boolean",
default: true,
description: "Controls whether the Dance keybindings are enabled.",
deprecationMessage: "dance.enabled is deprecated; disable the Dance extension instead.",
},
"dance.normalMode.lineHighlight": {
type: ["string", "null"],
default: "editor.hoverHighlightBackground",
markdownDescription:
"Controls the line highlighting applied to active lines in normal mode. "
+ "Can be an hex color, a [theme color]("
+ "https://code.visualstudio.com/api/references/theme-color) or null.",
markdownDeprecationMessage: builtinModesAreDeprecatedMessage,
},
"dance.insertMode.lineHighlight": {
type: ["string", "null"],
default: null,
markdownDescription:
"Controls the line highlighting applied to active lines in insert mode. "
+ "Can be an hex color, a [theme color]("
+ "https://code.visualstudio.com/api/references/theme-color) or null.",
markdownDeprecationMessage: builtinModesAreDeprecatedMessage,
},
"dance.normalMode.lineNumbers": {
enum: ["off", "on", "relative", "inherit"],
default: "relative",
description: "Controls the display of line numbers in normal mode.",
enumDescriptions: [
"No line numbers.",
"Absolute line numbers.",
"Relative line numbers.",
"Inherit from `editor.lineNumbers`.",
],
markdownDeprecationMessage: builtinModesAreDeprecatedMessage,
},
"dance.insertMode.lineNumbers": {
enum: ["off", "on", "relative", "inherit"],
default: "inherit",
description: "Controls the display of line numbers in insert mode.",
enumDescriptions: [
"No line numbers.",
"Absolute line numbers.",
"Relative line numbers.",
"Inherit from `editor.lineNumbers`.",
],
markdownDeprecationMessage: builtinModesAreDeprecatedMessage,
},
"dance.normalMode.cursorStyle": {
enum: [
"line",
"block",
"underline",
"line-thin",
"block-outline",
"underline-thin",
"inherit",
],
default: "inherit",
description: "Controls the cursor style in normal mode.",
markdownDeprecationMessage: builtinModesAreDeprecatedMessage,
},
"dance.insertMode.cursorStyle": {
enum: [
"line",
"block",
"underline",
"line-thin",
"block-outline",
"underline-thin",
"inherit",
],
default: "inherit",
description: "Controls the cursor style in insert mode.",
markdownDeprecationMessage: builtinModesAreDeprecatedMessage,
},
"dance.insertMode.selectionStyle": {
type: "object",
default: {
borderColor: "$editor.selectionBackground",
borderStyle: "solid",
borderWidth: "2px",
borderRadius: "1px",
},
description: "The style to apply to selections in insert mode.",
properties: (Object as any).fromEntries(
[
"backgroundColor",
"borderColor",
"borderStyle",
"borderWidth",
"borderRadius",
].map((x) => [x, { type: "string" }]),
),
markdownDeprecationMessage: builtinModesAreDeprecatedMessage,
},
"dance.selectionBehavior": {
enum: ["caret", "character"],
default: "caret",
description: "Controls how selections behave within VS Code.",
markdownEnumDescriptions: [
"Selections are anchored to carets, which is the native VS Code behavior; that is, "
+ "they are positioned *between* characters and can therefore be empty.",
"Selections are anchored to characters, like Kakoune; that is, they are positioned "
+ "*on* characters, and therefore cannot be empty. Additionally, one-character "
+ "selections will behave as if they were non-directional, like Kakoune.",
],
markdownDeprecationMessage: builtinModesAreDeprecatedMessage,
},
},
},
// Commands.
// ========================================================================
commands: modules.flatMap((module) => module.commands.map((x) => ({
command: x.id,
title: x.title,
category: "Dance",
}))),
menus: {
commandPalette: modules.flatMap((module) => module.commands.map((x) => ({
command: x.id,
when: x.when,
}))),
},
// Keybindings.
// ========================================================================
keybindings: (() => {
const keybindings = modules.flatMap((module) => module.keybindings),
alphanum = [..."ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"],
keysToAssign = new Set([...alphanum, ...alphanum.map((x) => `Shift+${x}`), ...",'"]);
for (const keybinding of keybindings) {
keysToAssign.delete(keybinding.key);
}
for (const keyToAssign of keysToAssign) {
keybindings.push({
command: "dance.ignore",
key: keyToAssign,
when: "editorTextFocus && dance.mode == 'normal'",
});
}
return keybindings;
})(),
},
});
// Save to package.json
// ============================================================================
export async function build(builder: Builder) {
const fs = await import("fs/promises");
await fs.writeFile(
`${__dirname}/package.json`,
JSON.stringify(pkg(await builder.getCommandModules()), undefined, 2) + "\n",
"utf-8",
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,391 +0,0 @@
import { writeFileSync } from "fs";
import { Command, additionalKeyBindings, commands } from "./commands";
// Key bindings
// ============================================================================
const keybindings: {
command: string;
key: string;
when: string;
args?: any;
}[] = additionalKeyBindings.concat();
const alphanum = [..."abcdefghijklmnopqrstuvwxyz0123456789"],
keysToAssign = new Set([...alphanum, ...alphanum.map((x) => `Shift+${x}`), ...",'"]);
for (const command of Object.values(commands)) {
for (const { key, when } of command.keybindings) {
keysToAssign.delete(key);
keybindings.push({ command: command.id, key, when });
}
}
for (const keyToAssign of keysToAssign) {
keybindings.push({
command: "dance.cancel",
key: keyToAssign,
when: "editorTextFocus && dance.mode == 'normal'",
});
}
// Menus
// ============================================================================
const menus: Record<
string,
{ items: Record<string, { text: string; command: string; args?: any[] }> }
> = {
object: {
items: {
"b()": {
command: Command.objectsPerformSelection,
args: [{ object: "parens" }],
text: "parenthesis block",
},
"B{}": {
command: Command.objectsPerformSelection,
args: [{ object: "braces" }],
text: "braces block",
},
"r[]": {
command: Command.objectsPerformSelection,
args: [{ object: "brackets" }],
text: "brackets block",
},
"a<>": {
command: Command.objectsPerformSelection,
args: [{ object: "angleBrackets" }],
text: "angle block",
},
'Q"': {
command: Command.objectsPerformSelection,
args: [{ object: "doubleQuoteString" }],
text: "double quote string",
},
"q'": {
command: Command.objectsPerformSelection,
args: [{ object: "singleQuoteString" }],
text: "single quote string",
},
"g`": {
command: Command.objectsPerformSelection,
args: [{ object: "graveQuoteString" }],
text: "grave quote string",
},
"w": {
command: Command.objectsPerformSelection,
args: [{ object: "word" }],
text: "word",
},
"W": {
command: Command.objectsPerformSelection,
args: [{ object: "WORD" }],
text: "WORD",
},
"s": {
command: Command.objectsPerformSelection,
args: [{ object: "sentence" }],
text: "sentence",
},
"p": {
command: Command.objectsPerformSelection,
args: [{ object: "paragraph" }],
text: "paragraph",
},
" ": {
command: Command.objectsPerformSelection,
args: [{ object: "whitespaces" }],
text: "whitespaces",
},
"i": {
command: Command.objectsPerformSelection,
args: [{ object: "indent" }],
text: "indent",
},
"n": {
command: Command.objectsPerformSelection,
args: [{ object: "number" }],
text: "number",
},
"u": {
command: Command.objectsPerformSelection,
args: [{ object: "argument" }],
text: "argument",
},
"c": {
command: Command.objectsPerformSelection,
args: [{ object: "custom" }],
text: "custom object desc",
},
},
},
};
for (const [suffix, desc] of [
["", "go to"],
[".extend", "extend to"],
]) {
menus["goto" + suffix] = {
items: {
"h": {
text: `${desc} line start`,
command: "dance.goto.lineStart" + suffix,
},
"l": { text: `${desc} line end`, command: "dance.goto.lineEnd" + suffix },
"i": {
text: `${desc} non-blank line start`,
command: "dance.goto.lineStart.nonBlank" + suffix,
},
"g": {
text: `${desc} first line`,
command: "dance.goto.firstLine" + suffix,
},
"k": {
text: `${desc} first line`,
command: "dance.goto.firstLine" + suffix,
},
"j": {
text: `${desc} last line`,
command: "dance.goto.lastLine" + suffix,
},
"e": {
text: `${desc} last char of last line`,
command: "dance.goto.lastCharacter" + suffix,
},
"t": {
text: `${desc} the first displayed line`,
command: "dance.goto.firstVisibleLine" + suffix,
},
"c": {
text: `${desc} the middle displayed line`,
command: "dance.goto.middleVisibleLine" + suffix,
},
"b": {
text: `${desc} the last displayed line`,
command: "dance.goto.lastVisibleLine" + suffix,
},
"f": {
text: `${desc} file whose name is selected`,
command: "dance.goto.selectedFile" + suffix,
},
".": {
text: `${desc} last buffer modification position`,
command: "dance.goto.lastModification" + suffix,
},
},
};
}
// Package information
// ============================================================================
const pkg = {
name: "dance",
displayName: "Dance",
description: "Make those cursors dance with Kakoune-inspired keybindings.",
version: "0.4.2",
license: "ISC",
publisher: "gregoire",
author: {
name: "Grégoire Geis",
email: "opensource@gregoirege.is",
},
repository: {
type: "git",
url: "https://github.com/71/dance.git",
},
readme: "README.md",
categories: ["Keymaps", "Other"],
main: "./out/src/extension.js",
engines: {
vscode: "^1.44.0",
},
scripts: {
"check": "eslint .",
"format": "eslint . --fix",
"generate": "ts-node ./commands/generate.ts && ts-node package.ts",
"vscode:prepublish": "yarn run generate && yarn run compile",
"compile": "tsc -p ./",
"watch": "tsc -watch -p ./",
"test": "yarn run compile && node ./out/test/run.js",
"package": "vsce package",
"publish": "vsce publish",
},
devDependencies: {
"@types/glob": "^7.1.1",
"@types/js-yaml": "^3.12.3",
"@types/mocha": "^8.0.3",
"@types/node": "^14.6.0",
"@types/vscode": "^1.44.0",
"@typescript-eslint/eslint-plugin": "^4.18.0",
"@typescript-eslint/parser": "^4.18.0",
"eslint": "^7.22.0",
"glob": "^7.1.6",
"js-yaml": "^3.13.0",
"mocha": "^8.1.1",
"source-map-support": "^0.5.19",
"ts-node": "^9.1.1",
"typescript": "^4.2.3",
"vsce": "^1.87.0",
"vscode-test": "^1.3.0",
},
activationEvents: ["*"],
contributes: {
configuration: {
type: "object",
title: "Dance",
properties: {
"dance.enabled": {
type: "boolean",
default: true,
description: "Controls whether the Dance keybindings are enabled.",
},
"dance.normalMode.lineHighlight": {
type: ["string", "null"],
default: "editor.hoverHighlightBackground",
markdownDescription:
"Controls the line highlighting applied to active lines in normal mode. "
+ "Can be an hex color, a [theme color]("
+ "https://code.visualstudio.com/api/references/theme-color) or null.",
},
"dance.insertMode.lineHighlight": {
type: ["string", "null"],
default: null,
markdownDescription:
"Controls the line highlighting applied to active lines in insert mode. "
+ "Can be an hex color, a [theme color]("
+ "https://code.visualstudio.com/api/references/theme-color) or null.",
},
"dance.normalMode.lineNumbers": {
enum: ["off", "on", "relative", "inherit"],
default: "relative",
description: "Controls the display of line numbers in normal mode.",
enumDescriptions: [
"No line numbers.",
"Absolute line numbers.",
"Relative line numbers.",
"Inherit from `editor.lineNumbers`.",
],
},
"dance.insertMode.lineNumbers": {
enum: ["off", "on", "relative", "inherit"],
default: "inherit",
description: "Controls the display of line numbers in insert mode.",
enumDescriptions: [
"No line numbers.",
"Absolute line numbers.",
"Relative line numbers.",
"Inherit from `editor.lineNumbers`.",
],
},
"dance.normalMode.cursorStyle": {
enum: [
"line",
"block",
"underline",
"line-thin",
"block-outline",
"underline-thin",
"inherit",
],
default: "inherit",
description: "Controls the cursor style in normal mode.",
},
"dance.insertMode.cursorStyle": {
enum: [
"line",
"block",
"underline",
"line-thin",
"block-outline",
"underline-thin",
"inherit",
],
default: "inherit",
description: "Controls the cursor style in insert mode.",
},
"dance.insertMode.selectionStyle": {
type: "object",
default: {
borderColor: "$editor.selectionBackground",
borderStyle: "solid",
borderWidth: "2px",
borderRadius: "1px",
},
description: "The style to apply to selections in insert mode.",
properties: (Object as any).fromEntries(
[
"backgroundColor",
"borderColor",
"borderStyle",
"borderWidth",
"borderRadius",
].map((x) => [x, { type: "string" }]),
),
},
"dance.selectionBehavior": {
enum: ["caret", "character"],
default: "caret",
description: "Controls how selections behave within VS Code.",
markdownEnumDescriptions: [
"Selections are anchored to carets, which is the native VS Code behavior; that is, "
+ "they are positioned *between* characters and can therefore be empty.",
"Selections are anchored to characters, like Kakoune; that is, they are positioned "
+ "*on* characters, and therefore cannot be empty. Additionally, one-character "
+ "selections will behave as if they were non-directional, like Kakoune.",
],
},
"dance.menus": {
type: "object",
additionalProperties: {
type: "object",
properties: {
items: {
type: "object",
additionalProperties: {
type: ["object", "null"],
properties: {
text: {
type: "string",
},
command: {
type: "string",
},
args: {
type: "array",
},
},
},
},
},
additionalProperties: false,
},
default: menus,
},
},
},
commands: Object.values(commands).map((x) => ({
command: x.id,
title: x.title,
description: x.description,
category: "Dance",
})),
keybindings,
},
};
// Save to package.json
// ============================================================================
writeFileSync("./package.json", JSON.stringify(pkg, undefined, 2) + "\n", "utf8");

98
recipes/README.md Normal file
View File

@ -0,0 +1,98 @@
Recipes
=======
This directory contains Dance "recipes" -- example configurations and commands
meant to show what can be done with Dance.
For more examples, please see the [test suite](../test) and the [Dance API](
../src/api) documentation, both of which contain many different tested
examples.
## Generating code using commented JavaScript code
The following script can be used:
```js
await run(Selections.map((text) => text.replace(/^\/\/ |START$[\s\S]+?END$/gm, "")));
```
Before:
```
// await replace((text) => text.replace(/(\/\/ START\n)([\s\S]+?)(\/\/ END\n)/m, (_, before, after) =>
^
// before + "const alphabet = " + JSON.stringify(
// Array.from({ length: 26 }, (_, i) => String.fromCharCode(97 + i)),
// undefined, 2,
// ) + "\n" + after);
// START
// END
^ 0
```
After:
```
// await replace((text) => text.replace(/(\/\/ START\n)([\s\S]+?)(\/\/ END\n)/m, (_, before, after) =>
^
// before + "const alphabet = " + JSON.stringify(
// Array.from({ length: 26 }, (_, i) => String.fromCharCode(97 + i)),
// undefined, 2,
// ) + ";\n" + after);
// START
const alphabet = [
"a",
"b",
"c",
"d",
"e",
"f",
"g",
"h",
"i",
"j",
"k",
"l",
"m",
"n",
"o",
"p",
"q",
"r",
"s",
"t",
"u",
"v",
"w",
"x",
"y",
"z"
];
// END
^ 0
```
## Using `jj` to escape `insert` mode
Using the `prefix` argument of the [`dance.openMenu`](../src/commands#openmenu)
command:
```json
{
"key": "j",
"command": "dance.openMenu",
"args": {
"menu": {
"items": {
"j": {
"text": "escape to Normal",
"command": "dance.modes.set.normal",
},
},
},
"prefix": "j",
},
"when": "editorTextFocus && dance.mode == 'insert'",
}
```
For more information, please refer to [this issue](
https://github.com/71/dance/issues/74#issuecomment-819557435).

90
recipes/evil-dance.md Normal file
View File

@ -0,0 +1,90 @@
# Evil Dance
Evil Dance is a script that provides additional features to Dance by hooking
into the main process of VS Code and communicating with the instance of Dance
in the extension host.
It could bring nice features, but is and will remain unstable. Use at your own
risks.
## The script
Add the following code to `<path to VS Code>/resources/app/out/vs/loader.js`:
```js
// Since modules are loaded asynchronously, try to load the script below every
// second. After 10 failed tries, give up.
let danceRetries = 0;
let danceRetryToken = setInterval(() => {
try {
const { CommandsRegistry } = require("vs/platform/commands/common/commands");
const { IModelService } = require("vs/editor/common/services/modelService");
const { ITextMateService } = require("vs/workbench/services/textMate/common/textMateService");
clearInterval(danceRetryToken);
CommandsRegistry.registerCommand(
"dance.tokenizeLines",
async (accessor, resource, startLine, endLine = startLine) => {
// Convert line numbers from 0-indexing to 1-indexing.
startLine++;
endLine++;
// Find model for specified document.
const modelService = accessor.get(IModelService),
model = modelService.getModel(resource);
if (model == null) {
return;
}
// Find grammar for specified document.
const textMateService = accessor.get(ITextMateService),
language = model.getLanguageIdentifier().language,
grammar = await textMateService.createGrammar(language);
if (grammar == null) {
return;
}
// Set-up state for the first line of the range.
let state = null;
for (let i = 1; i < startLine; i++) {
state = grammar.tokenizeLine(model.getLineContent(i), state).ruleStack;
}
// Tokenize lines in given range and add them to the result.
const tokenizedLines = [];
for (let i = startLine; i <= endLine; i++) {
const tokenizationResult = grammar.tokenizeLine(model.getLineContent(i), state);
tokenizedLines.push(tokenizationResult.tokens);
state = tokenizationResult.ruleStack;
}
return tokenizedLines;
},
);
} catch (e) {
if (danceRetries++ === 10) {
console.error("Could not register dance.tokenizeLines.", e);
clearInterval(danceRetryToken);
}
}
}, 1000);
```
This will register a global command `dance.tokenizeLines` that will query the
internal VS Code state and return token information for the given lines in the
specified document. Right now, it's no used anywhere, but I could see myself
making commands that use this information to better find what should or should
not be selected.
The path to `loader.js` can be found by running the following code in the
developer tools of VS Code:
```js
path.join(process.resourcesPath, "app", "out", "vs", "loader.js")
```

16
src/api/clipboard.ts Normal file
View File

@ -0,0 +1,16 @@
import * as vscode from "vscode";
import { Context } from "./context";
/**
* Copies the given text to the clipboard.
*/
export function copy(text: string) {
return Context.wrap(vscode.env.clipboard.writeText(text));
}
/**
* Returns the text in the clipboard.
*/
export function clipboard() {
return Context.wrap(vscode.env.clipboard.readText());
}

526
src/api/context.ts Normal file
View File

@ -0,0 +1,526 @@
import * as vscode from "vscode";
import { EditNotAppliedError, EditorRequiredError } from "./errors";
import { Selections } from "./selections";
import { CommandDescriptor } from "../commands";
import { PerEditorState } from "../state/editors";
import { Extension } from "../state/extension";
import { Mode, SelectionBehavior } from "../state/modes";
import { noUndoStops, performDummyEdit } from "../utils/misc";
let currentContext: ContextWithoutActiveEditor | undefined;
/**
* @see Context.WithoutActiveEditor
*/
class ContextWithoutActiveEditor {
/**
* Returns the current execution context, or throws an error if called outside
* of an execution context.
*/
public static get current() {
if (currentContext === undefined) {
throw new Error("attempted to access context object outside of execution context");
}
return currentContext;
}
/**
* Returns the current execution context, or `undefined` if called outside of
* an execution context.
*/
public static get currentOrUndefined() {
return currentContext;
}
/**
* Equivalent to calling `wrap` on `Context.current`. If there is no current
* context, it returns the `thenable` directly.
*/
public static wrap<T>(thenable: Thenable<T>) {
return this.currentOrUndefined?.wrap(thenable) ?? thenable;
}
/**
* Equivalent to calling `then` on the current context. If there is no current
* context, it returns the `thenable.then` directly.
*/
public static then<T, R>(
thenable: Thenable<T>,
onFulfilled?: (value: T) => R,
onRejected?: (reason: any) => R,
) {
return this.currentOrUndefined?.then(thenable, onFulfilled, onRejected)
?? thenable.then(onFulfilled, onRejected);
}
/**
* Equivalent to calling `setup` on the current context.
*/
public static setup() {
return this.current.setup();
}
public constructor(
/**
* The global extension state.
*/
public readonly extension: Extension,
/**
* The token used to cancel an operation running in the current context.
*/
public readonly cancellationToken: vscode.CancellationToken,
/**
* The descriptor of the command that led to the creation of this context.
*/
public readonly commandDescriptor?: CommandDescriptor,
) {}
/**
* Creates a new promise that executes within the current context.
*/
public createPromise<T>(
executor: (resolve: (value: T) => void, reject: (error: any) => void) => void,
) {
return this.wrap(new Promise<T>(executor));
}
/**
* Runs the given function within the current context.
*/
public run<T>(f: (context: this) => T) {
const previousContext = currentContext;
currentContext = this;
try {
return f(this);
} finally {
currentContext = previousContext;
}
}
/**
* Runs the given async function within the current context.
*/
public async runAsync<T>(f: (context: this) => T): Promise<T extends Thenable<infer R> ? R : T> {
const previousContext = currentContext;
currentContext = this;
try {
return await f(this) as any;
} finally {
currentContext = previousContext;
}
}
/**
* Returns a promise whose continuations will be wrapped in a way that
* preserves the current context.
*
* Await a call to `setup` in an async function to make ensure that all
* subsequent `await` expressions preserve the current context.
*/
public setup() {
return this.wrap(Promise.resolve());
}
/**
* Wraps the given promise in a way that preserves the current context in
* `then`.
*/
public wrap<T>(thenable: Thenable<T>): Thenable<T> {
return {
then: <R>(onFulfilled?: (value: T) => R, onRejected?: (reason: any) => R) => {
return this.then(thenable, onFulfilled, onRejected);
},
};
}
/**
* Wraps the continuation of a promise in order to preserve the current
* context.
*/
public then<T, R>(
thenable: Thenable<T>,
onFulfilled?: (value: T) => R,
onRejected?: (reason: any) => R,
) {
if (onFulfilled !== undefined) {
const f = onFulfilled;
onFulfilled = (value: T) => this.runAsync(() => f(value) as any) as any;
}
if (onRejected !== undefined) {
const f = onRejected;
onRejected = (reason: any) => this.runAsync(() => f(reason) as any) as any;
}
return this.wrap(thenable.then(onFulfilled, onRejected));
}
}
const enum ContextFlags {
None = 0,
ShouldInsertUndoStop = 1,
}
/**
* The context of execution of a script.
*/
export class Context extends ContextWithoutActiveEditor {
/**
* Returns the current execution context, or throws an error if called outside
* of an execution context or if the execution context does not have an
* active editor.
*/
public static get current() {
if (!(currentContext instanceof Context)) {
throw new Error("current context does not have an active text editor");
}
return currentContext;
}
/**
* Returns the current execution context, or `undefined` if called outside of
* an execution context or if the execution context does not have an active
* editor.
*/
public static get currentOrUndefined() {
if (currentContext === undefined || !(currentContext instanceof Context)) {
return undefined;
}
return currentContext;
}
private _flags = ContextFlags.None;
private _document: vscode.TextDocument;
private _editor: vscode.TextEditor;
private _mode: Mode;
/**
* The current `vscode.TextDocument`.
*/
public get document() {
return this._document;
}
/**
* The current `vscode.TextEditor`.
*
* Avoid accessing `editor.selections` -- selections may need to be
* transformed before being returned or updated, which is why
* `context.selections` should be preferred.
*/
public get editor() {
return this._editor as Omit<vscode.TextEditor, "selections">;
}
/**
* The `Mode` associated with the current `vscode.TextEditor`.
*/
public get mode() {
return this._mode;
}
/**
* The selection behavior for this context.
*
* @deprecated Try to avoid using this property.
*/
public get selectionBehavior() {
return this._mode.selectionBehavior;
}
/**
* The current selections.
*
* Selections returned by this property **may be different** from the ones
* returned by `editor.selections`. If the current selection behavior is
* `Character`, strictly forward-facing (i.e. `active > anchor`) selections
* will be made longer by one character.
*/
public get selections() {
const editor = this.editor as vscode.TextEditor;
if (this.selectionBehavior === SelectionBehavior.Character) {
return Selections.fromCharacterMode(editor.selections, editor.document);
}
return editor.selections;
}
/**
* Sets the current selections.
*
* If the current selection behavior is `Character`, strictly forward-facing
* (i.e. `active > anchor`) selections will be made shorter by one character.
*/
public set selections(selections: readonly vscode.Selection[]) {
const editor = this.editor as vscode.TextEditor;
if (this.selectionBehavior === SelectionBehavior.Character) {
selections = Selections.toCharacterMode(selections, editor.document);
}
editor.selections = selections as vscode.Selection[];
}
/**
* Equivalent to `selections[0]`.
*
* @see selections
*/
public get mainSelection() {
const editor = this.editor as vscode.TextEditor;
if (this.selectionBehavior === SelectionBehavior.Character) {
return Selections.fromCharacterMode([editor.selection], editor.document)[0];
}
return editor.selection;
}
public constructor(
state: PerEditorState,
cancellationToken: vscode.CancellationToken,
commandDescriptor?: CommandDescriptor,
) {
super(state.extension, cancellationToken, commandDescriptor);
this._document = state.editor.document;
this._editor = state.editor;
this._mode = state.mode;
}
/**
* Returns the mode-specific state for the current context.
*/
public getState() {
return this.extension.editors.getState(this._editor)!;
}
/**
* Performs changes on the editor of the context.
*/
public edit<T>(
f: (editBuilder: vscode.TextEditorEdit, selections: readonly vscode.Selection[],
document: vscode.TextDocument) => T,
) {
let value: T;
const document = this.document,
selections = f.length >= 2 ? this.selections : [];
return this.wrap(
this.editor.edit(
(editBuilder) => value = f(editBuilder, selections, document),
noUndoStops,
).then((succeeded) => {
EditNotAppliedError.throwIfNotApplied(succeeded);
this._flags |= ContextFlags.ShouldInsertUndoStop;
return value;
}),
);
}
/**
* Returns whether edits have been performed in this context but not committed
* with `insertUndoStop`.
*/
public hasEditsWithoutUndoStops() {
return (this._flags & ContextFlags.ShouldInsertUndoStop) === ContextFlags.ShouldInsertUndoStop;
}
/**
* Inserts an undo stop if needed.
*/
public insertUndoStop() {
if (!this.hasEditsWithoutUndoStops()) {
return Promise.resolve();
}
return this.wrap(performDummyEdit(this._editor));
}
/**
* Switches the context to the given document.
*/
public async switchToDocument(document: vscode.TextDocument, alsoFocusEditor = false) {
const editor = await vscode.window.showTextDocument(document, undefined, !alsoFocusEditor);
this._document = document;
this._editor = editor;
this._mode = this.extension.editors.getState(editor).mode;
}
/**
* Switches the mode of the current editor to the given mode.
*/
public switchToMode(mode: Mode) {
const state = this.extension.editors.getState(this._editor);
return state.setMode(mode).then(() => {
this._mode = state.mode;
});
}
}
export namespace Context {
/**
* The base `Context` class, which does not require an active
* `vscode.TextEditor`.
*/
export const WithoutActiveEditor = ContextWithoutActiveEditor;
export type WithoutActiveEditor = ContextWithoutActiveEditor;
/**
* Returns a `Context` or `Context.WithoutActiveEditor` depending on whether
* there is an active text editor.
*/
export function create(extension: Extension, command: CommandDescriptor) {
const activeEditorState = extension.editors.active,
cancellationToken = extension.cancellationToken;
return activeEditorState === undefined
? new Context.WithoutActiveEditor(extension, cancellationToken, command)
: new Context(activeEditorState, cancellationToken, command);
}
/**
* Returns a `Context` or throws an exception if there is no active text
* editor.
*/
export function createWithActiveTextEditor(extension: Extension, command: CommandDescriptor) {
const activeEditorState = extension.editors.active;
EditorRequiredError.throwUnlessAvailable(activeEditorState);
return new Context(activeEditorState, extension.cancellationToken, command);
}
}
/**
* Returns the text of the given range in the current context.
*
* ### Example
* ```js
* const start = new vscode.Position(0, 0),
* end = new vscode.Position(0, 3);
*
* expect(
* text(new vscode.Range(start, end)),
* "to be",
* "foo",
* );
* ```
*
* With:
* ```
* foo bar
* ```
*/
export function text(range: vscode.Range): string;
/**
* Returns the text of all the given ranges in the current context.
*
* ### Example
* ```js
* const start1 = new vscode.Position(0, 0),
* end1 = new vscode.Position(0, 3),
* start2 = new vscode.Position(0, 4),
* end2 = new vscode.Position(0, 7);
*
* expect(
* text([new vscode.Range(start1, end1), new vscode.Range(start2, end2)]),
* "to equal",
* ["foo", "bar"],
* );
* ```
*
* With:
* ```
* foo bar
* ```
*/
export function text(ranges: readonly vscode.Range[]): string[];
export function text(ranges: vscode.Range | readonly vscode.Range[]) {
const document = Context.current.document;
if (Array.isArray(ranges)) {
return ranges.map((range) => document.getText(range));
}
return document.getText(ranges as vscode.Range);
}
/**
* Performs changes on the active editor.
*
* ### Example
* ```js
* await edit((editBuilder) => {
* const start = new vscode.Position(0, 2),
* end = new vscode.Position(0, 4);
*
* editBuilder.delete(new vscode.Range(start, end));
* });
* ```
*
* Before:
* ```
* hello world
* ^^^^^ 0
* ```
*
* After:
* ```
* heo world
* ^^^ 0
* ```
*/
export function edit<T>(
f: (editBuilder: vscode.TextEditorEdit, selections: readonly vscode.Selection[],
document: vscode.TextDocument) => T,
editor?: vscode.TextEditor,
) {
if (editor !== undefined) {
let value: T;
return editor.edit(
(editBuilder) => value = f(editBuilder, editor!.selections, editor!.document),
noUndoStops,
).then((succeeded) => {
EditNotAppliedError.throwIfNotApplied(succeeded);
return value;
});
}
return Context.current.edit(f);
}
/**
* Marks a change, inserting a history undo stop.
*/
export function insertUndoStop(editor?: vscode.TextEditor) {
if (editor !== undefined) {
return performDummyEdit(editor);
}
return Context.current.insertUndoStop();
}

791
src/api/edit/index.ts Normal file
View File

@ -0,0 +1,791 @@
import * as vscode from "vscode";
import { TrackedSelection } from "../../utils/tracked-selection";
import { Context, edit } from "../context";
import { Positions } from "../positions";
import { rotateSelections, Selections } from "../selections";
const enum Constants {
PositionMask = 0b00_11_1,
BehaviorMask = 0b11_00_1,
}
function mapResults(
insertFlags: insert.Flags,
document: vscode.TextDocument,
selections: readonly vscode.Selection[],
replacements: readonly replace.Result[],
) {
let flags = TrackedSelection.Flags.Inclusive,
where = undefined as "start" | "end" | "active" | "anchor" | undefined;
switch (insertFlags & Constants.PositionMask) {
case insert.Flags.Active:
where = "active";
break;
case insert.Flags.Anchor:
where = "anchor";
break;
case insert.Flags.Start:
where = "start";
break;
case insert.Flags.End:
where = "end";
break;
}
if (where !== undefined && (insertFlags & Constants.BehaviorMask) === insert.Flags.Keep) {
flags = (insertFlags & Constants.PositionMask) === insert.Flags.Start
? TrackedSelection.Flags.StrictStart
: TrackedSelection.Flags.StrictEnd;
}
const savedSelections = TrackedSelection.fromArray(selections, document),
discardedSelections = new Uint8Array(selections.length);
const promise = edit((editBuilder) => {
for (let i = 0, len = replacements.length; i < len; i++) {
const result = replacements[i],
selection = selections[i];
if (result === undefined) {
editBuilder.delete(selection);
discardedSelections[i] = 1;
} else if (where === undefined) {
editBuilder.replace(selection, result);
if (TrackedSelection.length(savedSelections, i) !== result.length) {
const documentChangedEvent: vscode.TextDocumentContentChangeEvent[] = [{
range: selection,
rangeOffset: TrackedSelection.startOffset(savedSelections, i),
rangeLength: TrackedSelection.length(savedSelections, i),
text: result,
}];
TrackedSelection.updateAfterDocumentChanged(savedSelections, documentChangedEvent, flags);
}
} else {
const position = selection[where];
editBuilder.replace(position, result);
const selectionOffset = TrackedSelection.startOffset(savedSelections, i),
selectionLength = TrackedSelection.length(savedSelections, i);
const documentChangedEvent: vscode.TextDocumentContentChangeEvent[] = [{
range: new vscode.Range(position, position),
rangeOffset: position === selection.start
? selectionOffset
: selectionOffset + selectionLength,
rangeLength: 0,
text: result,
}];
TrackedSelection.updateAfterDocumentChanged(savedSelections, documentChangedEvent, flags);
}
}
}).then(() => {
const results: vscode.Selection[] = [];
for (let i = 0, len = discardedSelections.length; i < len; i++) {
if (discardedSelections[i]) {
continue;
}
let restoredSelection = TrackedSelection.restore(savedSelections, i, document);
if (where !== undefined && (insertFlags & Constants.BehaviorMask) === insert.Flags.Select) {
// Selections were extended; we now unselect the previously selected
// text.
const totalLength = TrackedSelection.length(savedSelections, i),
insertedLength = replacements[i]!.length,
previousLength = totalLength - insertedLength;
if (restoredSelection[where] === restoredSelection.start) {
restoredSelection = Selections.fromStartEnd(
restoredSelection.start,
Positions.offset(restoredSelection.end, -previousLength)!,
restoredSelection.isReversed,
);
} else {
restoredSelection = Selections.fromStartEnd(
Positions.offset(restoredSelection.start, previousLength)!,
restoredSelection.end,
restoredSelection.isReversed,
);
}
}
results.push(restoredSelection);
}
return results;
});
return Context.wrap(promise);
}
/**
* Inserts text next to the given selections according to the given function.
*
* @param f A mapping function called for each selection; given the text content
* and the index of the selection, it should return the new text content of
* the selection, or `undefined` if it is to be removed. Also works for
* `async` (i.e. `Promise`-returning) functions, in which case **all** results
* must be promises.
* @param selections If `undefined`, the selections of the active editor will be
* used. Otherwise, must be a `vscode.Selection` array which will be mapped
* in the active editor.
*
* ### Example
* ```js
* Selections.set(await insert(insert.Replace, (x) => `${+x * 2}`));
* ```
*
* Before:
* ```
* 1 2 3
* ^ 0
* ^ 1
* ^ 2
* ```
*
* After:
* ```
* 2 4 6
* ^ 0
* ^ 1
* ^ 2
* ```
*/
export function insert(
flags: insert.Flags,
f: insert.Callback<insert.Result> | insert.Callback<insert.AsyncResult>,
selections?: readonly vscode.Selection[],
): Thenable<vscode.Selection[]> {
return insert.byIndex(
flags,
(i, selection, document) => f(document.getText(selection), selection, i, document) as any,
selections,
);
}
export namespace insert {
/**
* Insertion flags for `insert`.
*/
export const enum Flags {
/**
* Replace text and select replacement text.
*/
Replace = 0,
/**
* Insert at active position of selection.
*/
Active = 0b00_00_1,
/**
* Insert at start of selection.
*/
Start = 0b00_01_1,
/**
* Insert at end of selection.
*/
End = 0b00_10_1,
/**
* Insert at anchor of selection.
*/
Anchor = 0b00_11_1,
/**
* Keep current selections.
*/
Keep = 0b00_00_1,
/**
* Select inserted text only.
*/
Select = 0b01_00_1,
/**
* Extend to inserted text.
*/
Extend = 0b10_00_1,
}
export const Replace = Flags.Replace,
Start = Flags.Start,
End = Flags.End,
Active = Flags.Active,
Anchor = Flags.Anchor,
Keep = Flags.Keep,
Select = Flags.Select,
Extend = Flags.Extend;
export function flagsAtEdge(edge?: "active" | "anchor" | "start" | "end") {
switch (edge) {
case undefined:
return Flags.Replace;
case "active":
return Flags.Active;
case "anchor":
return Flags.Anchor;
case "start":
return Flags.Start;
case "end":
return Flags.End;
}
}
/**
* The result of a callback passed to `insert` or `insert.byIndex`.
*/
export type Result = string | undefined;
/**
* The result of an async callback passed to `insert` or `insert.byIndex`.
*/
export type AsyncResult = Thenable<Result>;
/**
* A callback passed to `insert`.
*/
export interface Callback<T> {
(text: string, selection: vscode.Selection, index: number, document: vscode.TextDocument): T;
}
/**
* A callback passed to `insert.byIndex`.
*/
export interface ByIndexCallback<T> {
(index: number, selection: vscode.Selection, document: vscode.TextDocument): T;
}
/**
* Inserts text next to the given selections according to the given function.
*
* @param f A mapping function called for each selection; given the index,
* range and editor of each selection, it should return the new text content
* of the selection, or `undefined` if it is to be removed. Also works for
* `async` (i.e. `Promise`-returning) functions, in which case **all**
* results must be promises.
* @param selections If `undefined`, the selections of the active editor will
* be used. Otherwise, must be a `vscode.Selection` array which will be
* mapped in the active editor.
*
* ### Example
* ```js
* Selections.set(await insert.byIndex(insert.Start, (i) => `${i + 1}`));
* ```
*
* Before:
* ```
* a b c
* ^ 0
* ^ 1
* ^ 2
* ```
*
* After:
* ```
* 1a 2b 3c
* ^ 0
* ^ 1
* ^ 2
* ```
*
* ### Example
* ```js
* Selections.set(await insert.byIndex(insert.Start | insert.Select, (i) => `${i + 1}`));
* ```
*
* Before:
* ```
* a b c
* ^ 0
* ^ 1
* ^ 2
* ```
*
* After:
* ```
* 1a 2b 3c
* ^ 0
* ^ 1
* ^ 2
* ```
*
* ### Example
* ```js
* Selections.set(await insert.byIndex(insert.Start | insert.Extend, (i) => `${i + 1}`));
* ```
*
* Before:
* ```
* a b c
* ^ 0
* ^ 1
* ^ 2
* ```
*
* After:
* ```
* 1a 2b 3c
* ^^ 0
* ^^ 1
* ^^ 2
* ```
*
* ### Example
* ```js
* Selections.set(await insert.byIndex(insert.End, (i) => `${i + 1}`));
* ```
*
* Before:
* ```
* a b c
* ^ 0
* ^ 1
* ^ 2
* ```
*
* After:
* ```
* a1 b2 c3
* ^ 0
* ^ 1
* ^ 2
* ```
*
* ### Example
* ```js
* Selections.set(await insert.byIndex(insert.End | insert.Select, (i) => `${i + 1}`));
* ```
*
* Before:
* ```
* a b c
* ^ 0
* ^ 1
* ^ 2
* ```
*
* After:
* ```
* a1 b2 c3
* ^ 0
* ^ 1
* ^ 2
* ```
*
* ### Example
* ```js
* Selections.set(await insert.byIndex(insert.End | insert.Extend, (i) => `${i + 1}`));
* ```
*
* Before:
* ```
* a b c
* ^ 0
* ^ 1
* ^ 2
* ```
*
* After:
* ```
* a1 b2 c3
* ^^ 0
* ^^ 1
* ^^ 2
* ```
*/
export function byIndex(
flags: Flags,
f: ByIndexCallback<Result> | ByIndexCallback<AsyncResult>,
selections: readonly vscode.Selection[] = Context.current.selections,
): Thenable<vscode.Selection[]> {
if (selections.length === 0) {
return Context.wrap(Promise.resolve([]));
}
const document = Context.current.document,
firstResult = f(0, selections[0], document);
if (typeof firstResult === "object") {
// `f` returns promises.
const promises = [firstResult];
for (let i = 1, len = selections.length; i < len; i++) {
promises.push(f(i, selections[i], document) as AsyncResult);
}
return Context.wrap(
Promise
.all(promises)
.then((results) => mapResults(flags, document, selections, results)),
);
}
// `f` returns regular values.
const allResults: Result[] = [firstResult];
for (let i = 1, len = selections.length; i < len; i++) {
allResults.push(f(i, selections[i], document) as Result);
}
return mapResults(flags, document, selections, allResults);
}
export namespace byIndex {
/**
* Same as `insert.byIndex`, but also inserts strings that end with a
* newline character on the next or previous line.
*/
export async function withFullLines(
flags: Flags,
f: ByIndexCallback<Result> | ByIndexCallback<AsyncResult>,
selections: readonly vscode.Selection[] = Context.current.selections,
) {
const document = Context.current.document,
allResults = await Promise.all(selections.map((sel, i) => f(i, sel, document)));
// Separate full-line results from all results.
const results: Result[] = [],
resultsSelections: vscode.Selection[] = [],
fullLineResults: Result[] = [],
fullLineResultsSelections: vscode.Selection[] = [],
isFullLines: boolean[] = [];
for (let i = 0; i < allResults.length; i++) {
const result = allResults[i];
if (result === undefined) {
continue;
}
if (result.endsWith("\n")) {
fullLineResults.push(result);
fullLineResultsSelections.push(selections[i]);
isFullLines.push(true);
} else {
results.push(result);
resultsSelections.push(selections[i]);
isFullLines.push(false);
}
}
if (fullLineResults.length === 0) {
return await mapResults(flags, document, resultsSelections, results);
}
let savedSelections = new TrackedSelection.Set(
TrackedSelection.fromArray(fullLineResultsSelections, document),
document,
);
// Insert non-full lines.
const normalSelections = await mapResults(flags, document, resultsSelections, results);
// Insert full lines.
const fullLineSelections = savedSelections.restore();
savedSelections.dispose();
const nextFullLineSelections: vscode.Selection[] = [],
insertionPositions: vscode.Position[] = [];
if ((flags & Constants.PositionMask) === Flags.Start) {
for (const selection of fullLineSelections) {
const insertionPosition = Positions.lineStart(selection.start.line);
insertionPositions.push(insertionPosition);
if ((flags & Constants.BehaviorMask) === Flags.Extend) {
nextFullLineSelections.push(
Selections.fromStartEnd(
insertionPosition, selection.end, selection.isReversed, document),
);
} else if ((flags & Constants.BehaviorMask) === Flags.Select) {
nextFullLineSelections.push(Selections.empty(insertionPosition));
} else {
// Keep selection as is.
nextFullLineSelections.push(selection);
}
}
} else {
for (const selection of fullLineSelections) {
const insertionPosition = Positions.lineStart(Selections.endLine(selection) + 1);
insertionPositions.push(insertionPosition);
if ((flags & Constants.BehaviorMask) === Flags.Extend) {
nextFullLineSelections.push(
Selections.fromStartEnd(
selection.start, insertionPosition, selection.isReversed, document),
);
} else if ((flags & Constants.BehaviorMask) === Flags.Select) {
nextFullLineSelections.push(Selections.empty(insertionPosition));
} else {
// Keep selection as is.
nextFullLineSelections.push(selection);
}
}
}
savedSelections = new TrackedSelection.Set(
TrackedSelection.fromArray(nextFullLineSelections, document),
document,
(flags & Constants.BehaviorMask) === Flags.Keep
? TrackedSelection.Flags.Strict
: TrackedSelection.Flags.Inclusive,
);
await edit((editBuilder) => {
for (let i = 0; i < insertionPositions.length; i++) {
editBuilder.replace(insertionPositions[i], fullLineResults[i]!);
}
});
const finalFullLineSelections = savedSelections.restore();
savedSelections.dispose();
// Merge back selections.
const allSelections: vscode.Selection[] = [];
for (let i = 0, normalIdx = 0, fullLineIdx = 0; i < isFullLines.length; i++) {
if (isFullLines[i]) {
allSelections.push(finalFullLineSelections[fullLineIdx++]);
} else {
allSelections.push(normalSelections[normalIdx++]);
}
}
return allSelections;
}
}
}
/**
* Replaces the given selections according to the given function.
*
* @param f A mapping function called for each selection; given the text content
* and the index of the selection, it should return the new text content of
* the selection, or `undefined` if it is to be removed. Also works for
* `async` (i.e. `Promise`-returning) functions, in which case **all** results
* must be promises.
* @param selections If `undefined`, the selections of the active editor will be
* used. Otherwise, must be a `vscode.Selection` array which will be mapped
* in the active editor.
*
* ### Example
* ```js
* await replace((x) => `${+x * 2}`);
* ```
*
* Before:
* ```
* 1 2 3
* ^ 0
* ^ 1
* ^ 2
* ```
*
* After:
* ```
* 2 4 6
* ^ 0
* ^ 1
* ^ 2
* ```
*/
export function replace(
f: replace.Callback<replace.Result> | replace.Callback<replace.AsyncResult>,
selections?: readonly vscode.Selection[],
): Thenable<vscode.Selection[]> {
return insert(insert.Flags.Replace, f, selections);
}
export namespace replace {
/**
* The result of a callback passed to `replace` or `replace.byIndex`.
*/
export type Result = string | undefined;
/**
* The result of an async callback passed to `replace` or `replace.byIndex`.
*/
export type AsyncResult = Thenable<Result>;
/**
* A callback passed to `replace`.
*/
export interface Callback<T> {
(text: string, selection: vscode.Selection, index: number, document: vscode.TextDocument): T;
}
/**
* A callback passed to `replace.byIndex`.
*/
export interface ByIndexCallback<T> {
(index: number, selection: vscode.Selection, document: vscode.TextDocument): T;
}
/**
* Replaces the given selections according to the given function.
*
* @param f A mapping function called for each selection; given the index,
* range and editor of each selection, it should return the new text content
* of the selection, or `undefined` if it is to be removed. Also works for
* `async` (i.e. `Promise`-returning) functions, in which case **all**
* results must be promises.
* @param selections If `undefined`, the selections of the active editor will
* be used. Otherwise, must be a `vscode.Selection` array which will be
* mapped in the active editor.
*
* ### Example
* ```js
* await replace.byIndex((i) => `${i + 1}`);
* ```
*
* Before:
* ```
* a b c
* ^ 0
* ^ 1
* ^ 2
* ```
*
* After:
* ```
* 1 2 3
* ^ 0
* ^ 1
* ^ 2
* ```
*/
export function byIndex(
f: ByIndexCallback<Result> | ByIndexCallback<AsyncResult>,
selections?: readonly vscode.Selection[],
): Thenable<vscode.Selection[]> {
return insert.byIndex(insert.Flags.Replace, f, selections);
}
}
/**
* Rotates the given selections and their contents by the given offset.
*
* @param selections If `undefined`, the selections of the active editor will be
* used. Otherwise, must be a `vscode.Selection` array which will be mapped
* in the active editor.
*
* ### Example
* ```js
* await rotate(1);
* ```
*
* Before:
* ```
* a b c
* ^ 0
* ^ 1
* ^ 2
* ```
*
* After:
* ```
* b c a
* ^ 1
* ^ 2
* ^ 0
* ```
*/
export function rotate(by: number, selections?: readonly vscode.Selection[]) {
return rotate
.contentsOnly(by, selections)
.then((selections) => rotate.selectionsOnly(by, selections));
}
export namespace rotate {
/**
* Rotates the contents of the given selections by the given offset.
*
* @see rotate
*
* ### Example
* ```js
* await rotate.contentsOnly(1);
* ```
*
* Before:
* ```
* a b c
* ^ 0
* ^ 1
* ^ 2
* ```
*
* After:
* ```
* b c a
* ^ 0
* ^ 1
* ^ 2
* ```
*/
export function contentsOnly(
by: number,
selections: readonly vscode.Selection[] = Context.current.selections,
) {
const len = selections.length;
// Handle negative values for `by`:
by = (by % len) + len;
if (by === len) {
return Context.wrap(Promise.resolve(selections.slice()));
}
return replace.byIndex(
(i, _, document) => document.getText(selections[(i + by) % len]),
selections,
);
}
/**
* Rotates the given selections (but not their contents) by the given offset.
*
* @see rotate
*
* ### Example
* ```js
* rotate.selectionsOnly(1);
* ```
*
* Before:
* ```
* a b c
* ^ 0
* ^ 1
* ^ 2
* ```
*
* After:
* ```
* a b c
* ^ 1
* ^ 2
* ^ 0
* ```
*/
export function selectionsOnly(by: number, selections?: readonly vscode.Selection[]) {
Selections.set(rotateSelections(by, selections));
}
}

339
src/api/edit/linewise.ts Normal file
View File

@ -0,0 +1,339 @@
import * as vscode from "vscode";
import { Context, edit } from "../context";
import { blankCharacters } from "../../utils/charset";
/**
* Increases the indentation of the given lines by the given count.
*
* ### Example
* ```js
* await indentLines([0, 1, 3]);
* ```
*
* Before:
* ```
* a
*
* c
* d
* ```
*
* After:
* ```
* a
*
* c
* d
* ```
*
* ### Example
* ```js
* await indentLines([0], 2);
* ```
*
* Before:
* ```
* a
* ```
*
* After:
* ```
* a
* ```
*
* ### Example
* ```js
* await indentLines([0, 1], 1, true);
* ```
*
* Before:
* ```
* a
*
* ```
*
* After:
* ```
* a
* ··
* ```
*/
export function indentLines(lines: Iterable<number>, times = 1, indentEmpty = false) {
const options = Context.current.editor.options,
indent = options.insertSpaces
? " ".repeat(options.tabSize as number * times)
: "\t".repeat(times);
if (indentEmpty) {
return edit((editBuilder) => {
const seen = new Set<number>();
for (const line of lines) {
const cnt = seen.size;
if (seen.add(line).size === cnt) {
// Avoid indenting the same line more than once.
continue;
}
editBuilder.insert(new vscode.Position(line, 0), indent);
}
});
} else {
return edit((editBuilder, _, document) => {
const seen = new Set<number>();
for (const line of lines) {
const cnt = seen.size;
if (seen.add(line).size === cnt || document.lineAt(line).isEmptyOrWhitespace) {
// Avoid indenting empty lines or the same line more than once.
continue;
}
editBuilder.insert(new vscode.Position(line, 0), indent);
}
});
}
}
/**
* Decreases the indentation of the given lines by the given count.
*
* ### Example
* ```js
* await deindentLines([0, 1, 3]);
* ```
*
* Before:
* ```
* a
* ··
* c
* d
* ```
*
* After:
* ```
* a
*
* c
* d
* ```
*
* ### Example
* ```js
* await deindentLines([0, 1, 3], 2);
* ```
*
* Before:
* ```
* a
* ··
* c
* d
* ```
*
* After:
* ```
* a
*
* c
* d
* ```
*/
export function deindentLines(lines: Iterable<number>, times = 1, deindentIncomplete = true) {
return edit((editBuilder, _, document) => {
const tabSize = Context.current.editor.options.tabSize as number,
needed = times * tabSize,
seen = new Set<number>();
for (const line of lines) {
const cnt = seen.size;
if (seen.add(line).size === cnt) {
// Avoid deindenting the same line more than once.
continue;
}
const textLine = document.lineAt(line),
text = textLine.text;
let column = 0, // Column, accounting for tab size.
j = 0; // Index in source line, and number of characters to remove.
for (; column < needed; j++) {
const char = text[j];
if (char === "\t") {
column += tabSize;
} else if (char === " ") {
column++;
} else {
break;
}
}
if (!deindentIncomplete && j < text.length) {
j -= j % tabSize;
}
if (j !== 0) {
editBuilder.delete(textLine.range.with(undefined, textLine.range.start.translate(0, j)));
}
}
});
}
/**
* Joins all consecutive lines in the given list together with the given
* separator.
*
* ### Example
* ```js
* await joinLines([0]);
* ```
*
* Before:
* ```
* a b
* c d
* e f
* g h
* ```
*
* After:
* ```
* a b c d
* e f
* g h
* ```
*
* ### Example
* ```js
* await joinLines([0, 1]);
* ```
*
* Before:
* ```
* a b
* c d
* e f
* g h
* ```
*
* After:
* ```
* a b c d
* e f
* g h
* ```
*
* ### Example
* ```js
* await joinLines([0, 2]);
* ```
*
* Before:
* ```
* a b
* c d
* e f
* g h
* ```
*
* After:
* ```
* a b c d
* e f g h
* ```
*
* ### Example
* ```js
* await joinLines([1], " ");
* ```
*
* Before:
* ```
* a b
* c d
* e f
* g h
* ```
*
* After:
* ```
* a b
* c d e f
* g h
* ```
*/
export function joinLines(lines: Iterable<number>, separator: string = " ") {
// Sort lines (no need to dedup).
const sortedLines = [...lines].sort((a, b) => a - b);
if (sortedLines.length === 0) {
return Context.current.wrap(Promise.resolve([]));
}
// Determine all ranges; (range[i], range[i + 1]) <=> (startLine, length).
const ranges = [sortedLines[0], 0] as number[];
for (let i = 1, len = sortedLines.length; i < len; i++) {
const line = sortedLines[i],
lastLine = sortedLines[i - 1];
if (line === lastLine) {
continue;
} else if (line === lastLine + 1) {
ranges[ranges.length - 1]++;
} else {
ranges.push(line, 0);
}
}
return edit((editBuilder, _, document) => {
let diff = 0;
const selections = [] as vscode.Selection[];
// Perform edit on each line.
for (let i = 0, len = ranges.length; i < len; i += 2) {
const startLine = ranges[i],
count = ranges[i + 1] || 1;
let prevLine = document.lineAt(startLine),
currentEnd = 0;
for (let j = 0; j < count; j++) {
const nextLine = document.lineAt(startLine + j + 1);
// Find index of last non-whitespace character.
let endCharacter = prevLine.text.length;
while (endCharacter > 0) {
if (!blankCharacters.includes(prevLine.text[endCharacter - 1])) {
break;
}
endCharacter--;
}
const start = new vscode.Position(prevLine.lineNumber, endCharacter),
end = new vscode.Position(nextLine.lineNumber,
nextLine.firstNonWhitespaceCharacterIndex),
finalCharacter = currentEnd + endCharacter,
finalStart = new vscode.Position(startLine - diff, finalCharacter),
finalEnd = new vscode.Position(startLine - diff, finalCharacter + separator.length);
editBuilder.replace(new vscode.Range(start, end), separator);
selections.push(new vscode.Selection(finalStart, finalEnd));
prevLine = nextLine;
currentEnd = finalCharacter + separator.length - nextLine.firstNonWhitespaceCharacterIndex;
}
diff += count;
}
return selections;
});
}

214
src/api/errors.ts Normal file
View File

@ -0,0 +1,214 @@
import * as vscode from "vscode";
import { Context } from "./context";
import { PerEditorState } from "../state/editors";
/**
* Asserts that the given condition is true.
*/
export function assert(condition: boolean): asserts condition {
if (!condition) {
const error = new Error(
"internal assertion failed; please report this error on https://github.com/71/dance/issues. "
+ "its stacktrace is available in the developer console (Command Palette > Open Developer "
+ "Tools).",
);
// Log error to ensure its stacktrace can be found.
console.error(error);
throw error;
}
}
/**
* Throws an exception indicating that the caller is not implemented yet.
*/
export function todo(): never {
const context = Context.WithoutActiveEditor.currentOrUndefined;
if (context?.commandDescriptor !== undefined) {
throw new Error(`command not implemented: ${context.commandDescriptor.identifier}`);
}
throw new Error("function not implemented");
}
/**
* An error thrown when no selections remain.
*/
export class EmptySelectionsError extends Error {
public constructor(message = "no selections remain") {
super(message);
}
/**
* Throws if the given selections are empty.
*/
public static throwIfEmpty(selections: readonly vscode.Selection[]) {
if (selections.length === 0) {
throw new EmptySelectionsError();
}
}
/**
* Throws if the selections of the given register are empty.
*/
public static throwIfRegisterIsEmpty<T>(
selections: readonly T[] | undefined,
registerName: string,
): asserts selections is readonly T[] {
if (selections === undefined || selections.length === 0) {
throw new EmptySelectionsError(`no selections are saved in register "${registerName}"`);
}
}
}
/**
* Error thrown when a given argument is not as expected.
*/
export class ArgumentError extends Error {
public constructor(message: string, public readonly argumentName?: string) {
super(message);
}
public static validate(
argumentName: string,
condition: boolean,
message: string | (() => string),
): asserts condition {
if (!condition) {
if (typeof message === "function") {
message = message();
}
throw new ArgumentError(message, argumentName);
}
}
}
/**
* Error thrown when a user input is not as expected.
*/
export class InputError extends ArgumentError {
public constructor(message: string) {
super(message, "input");
}
public static validateInput(
condition: boolean,
message: string,
): asserts condition {
if (!condition) {
throw new this(message);
}
}
}
/**
* Error thrown when a function that is expected to return a selection returns
* something else.
*/
export class NotASelectionError extends ArgumentError {
public constructor(public readonly value: unknown) {
super("value is not a selection");
}
/**
* Throws if the given value is not a `vscode.Selection`.
*/
public static throwIfNotASelection(value: unknown): asserts value is vscode.Selection {
if (!(value instanceof vscode.Selection)) {
throw new NotASelectionError(value);
}
}
/**
* Throws if the given list contains a value that is not a `vscode.Selection`,
* or if the list is empty.
*/
public static throwIfNotASelectionArray(value: unknown): asserts value is vscode.Selection[] {
if (!Array.isArray(value) || value.length === 0) {
throw new EmptySelectionsError();
}
for (let i = 0, len = value.length; i < len; i++) {
NotASelectionError.throwIfNotASelection(value[i]);
}
}
}
/**
* Error thrown when an action requiring an editor is executed without an
* active `vscode.TextEditor`.
*/
export class EditorRequiredError extends Error {
public constructor() {
super("active editor required");
}
public static throwUnlessAvailable<T extends PerEditorState | vscode.TextEditor>(
editorState: T | undefined,
): asserts editorState is T {
if (editorState === undefined) {
throw new EditorRequiredError();
}
}
}
/**
* Error thrown when a cancellation is requested.
*/
export class CancellationError extends Error {
public constructor(
public readonly reason: CancellationError.Reason,
) {
super(reason);
}
public static throwIfCancellationRequested(token: vscode.CancellationToken) {
if (token.isCancellationRequested) {
throw new CancellationError(CancellationError.Reason.CancellationToken);
}
}
}
export namespace CancellationError {
export const enum Reason {
CancellationToken = "cancellation token was used",
PressedEscape = "user pressed <escape>",
}
}
/**
* Error thrown when two arrays that are expected to have the same length have
* different lengths
*/
export class LengthMismatchError extends Error {
public constructor() {
super("length mismatch");
}
public static throwIfLengthMismatch<A, B>(a: readonly A[], b: readonly B[]) {
if (a.length !== b.length) {
throw new LengthMismatchError();
}
}
}
/**
* An error thrown when a `TextEditor.edit` call returns `false`.
*/
export class EditNotAppliedError extends Error {
public constructor() {
super("TextEditor edit failed");
}
/**
* Throws if the given value is `false`.
*/
public static throwIfNotApplied(editWasApplied: boolean): asserts editWasApplied {
if (!editWasApplied) {
throw new EditNotAppliedError();
}
}
}

416
src/api/functional.ts Normal file
View File

@ -0,0 +1,416 @@
import * as vscode from "vscode";
/**
* A VS Code `Position`, `Range` or `Selection`.
*/
export type PRS = vscode.Position | vscode.Range | vscode.Selection;
/**
* A VS Code `Position` or `Selection`.
*/
export type PS = vscode.Position | vscode.Selection;
/**
* Returns whether the given value is a `vscode.Position` object.
*
* ### Example
*
* ```js
* const position = new vscode.Position(0, 0),
* range = new vscode.Range(position, position),
* selection = new vscode.Selection(position, position);
*
* assert(isPosition(position));
* assert(!isPosition(range));
* assert(!isPosition(selection));
* ```
*/
export function isPosition(x: unknown): x is vscode.Position {
return x != null && (x as object).constructor === vscode.Position;
}
/**
* Returns whether the given value is a `vscode.Range` object.
*
* ### Example
*
* ```js
* const position = new vscode.Position(0, 0),
* range = new vscode.Range(position, position),
* selection = new vscode.Selection(position, position);
*
* assert(!isRange(position));
* assert(isRange(range));
* assert(!isRange(selection));
* ```
*/
export function isRange(x: unknown): x is vscode.Range {
return x != null && (x as object).constructor === vscode.Range;
}
/**
* Returns whether the given value is a `vscode.Selection` object.
*
* ### Example
*
* ```js
* const position = new vscode.Position(0, 0),
* range = new vscode.Range(position, position),
* selection = new vscode.Selection(position, position);
*
* assert(!isSelection(position));
* assert(!isSelection(range));
* assert(isSelection(selection));
* ```
*/
export function isSelection(x: unknown): x is vscode.Selection {
return x != null && (x as object).constructor === vscode.Selection;
}
/**
* Returns a `PRS` whose start position is mapped using the given function.
*
* ### Example
*
* ```js
* const p1 = new vscode.Position(0, 0),
* p2 = new vscode.Position(0, 1);
*
* assert.deepStrictEqual(
* mapStart(p1, (x) => x.translate(1)),
* new vscode.Position(1, 0),
* );
* assert.deepStrictEqual(
* mapStart(new vscode.Range(p1, p2), (x) => x.translate(1)),
* new vscode.Range(p2, new vscode.Position(1, 0)),
* );
* assert.deepStrictEqual(
* mapStart(new vscode.Selection(p1, p2), (x) => x.translate(1)),
* new vscode.Selection(new vscode.Position(1, 0), p2),
* );
* assert.deepStrictEqual(
* mapStart(new vscode.Selection(p2, p1), (x) => x.translate(1)),
* new vscode.Selection(p2, new vscode.Position(1, 0)),
* );
* ```
*/
export function mapStart<T extends PRS>(x: T, f: (_: vscode.Position) => vscode.Position) {
if (isSelection(x)) {
return x.start === x.anchor
? new vscode.Selection(f(x.start), x.end) as T
: new vscode.Selection(x.end, f(x.start)) as T;
}
if (isRange(x)) {
return new vscode.Range(f(x.start), x.end) as T;
}
return f(x as vscode.Position) as T;
}
/**
* Returns a `PRS` whose end position is mapped using the given function.
*
* ### Example
*
* ```js
* const p1 = new vscode.Position(0, 0),
* p2 = new vscode.Position(0, 1);
*
* assert.deepStrictEqual(
* mapEnd(p1, (x) => x.translate(1)),
* new vscode.Position(1, 0),
* );
* assert.deepStrictEqual(
* mapEnd(new vscode.Range(p1, p2), (x) => x.translate(1)),
* new vscode.Range(p1, new vscode.Position(1, 1)),
* );
* assert.deepStrictEqual(
* mapEnd(new vscode.Selection(p1, p2), (x) => x.translate(1)),
* new vscode.Selection(p1, new vscode.Position(1, 1)),
* );
* assert.deepStrictEqual(
* mapEnd(new vscode.Selection(p2, p1), (x) => x.translate(1)),
* new vscode.Selection(new vscode.Position(1, 1), p1),
* );
* ```
*/
export function mapEnd<T extends PRS>(x: T, f: (_: vscode.Position) => vscode.Position) {
if (isSelection(x)) {
return x.start === x.anchor
? new vscode.Selection(x.start, f(x.end)) as T
: new vscode.Selection(f(x.end), x.start) as T;
}
if (isRange(x)) {
return new vscode.Range(x.start, f(x.end)) as T;
}
return f(x as vscode.Position) as T;
}
/**
* Returns a `PS` whose active position is mapped using the given function.
*
* ### Example
*
* ```js
* const p1 = new vscode.Position(0, 0),
* p2 = new vscode.Position(0, 1);
*
* assert.deepStrictEqual(
* mapActive(p1, (x) => x.translate(1)),
* new vscode.Position(1, 0),
* );
* assert.deepStrictEqual(
* mapActive(new vscode.Selection(p1, p2), (x) => x.translate(1)),
* new vscode.Selection(p1, new vscode.Position(1, 1)),
* );
* ```
*/
export function mapActive<T extends PS>(x: T, f: (_: vscode.Position) => vscode.Position) {
if (isSelection(x)) {
return new vscode.Selection(x.anchor, f(x.active)) as T;
}
return f(x as vscode.Position) as T;
}
/**
* Returns a `PRS` whose start and end positions are mapped using the given
* function.
*
* ### Example
*
* ```js
* const p1 = new vscode.Position(0, 0),
* p2 = new vscode.Position(0, 1);
*
* assert.deepStrictEqual(
* mapBoth(p1, (x) => x.translate(1)),
* new vscode.Position(1, 0),
* );
* assert.deepStrictEqual(
* mapBoth(new vscode.Range(p1, p2), (x) => x.translate(1)),
* new vscode.Range(new vscode.Position(1, 0), new vscode.Position(1, 1)),
* );
* assert.deepStrictEqual(
* mapBoth(new vscode.Selection(p1, p2), (x) => x.translate(1)),
* new vscode.Selection(new vscode.Position(1, 0), new vscode.Position(1, 1)),
* );
* assert.deepStrictEqual(
* mapBoth(new vscode.Selection(p2, p1), (x) => x.translate(1)),
* new vscode.Selection(new vscode.Position(1, 1), new vscode.Position(1, 0)),
* );
* ```
*/
export function mapBoth<T extends PRS>(x: T, f: (_: vscode.Position) => vscode.Position) {
if (isSelection(x)) {
return new vscode.Selection(f(x.anchor), f(x.active)) as T;
}
if (isRange(x)) {
return new vscode.Range(f(x.start), f(x.end)) as T;
}
return f(x as vscode.Position) as T;
}
/**
* Given a function type with at least one parameter, returns a pair of all the
* argument types except the last one, and then the last argument type.
*/
export type SplitParameters<F> = F extends (...args: infer AllArgs) => any
? AllArgs extends [...infer Args, infer LastArg] ? [Args, LastArg] : never
: never;
/**
* Returns a function that takes the `n - 1` first parameters of `f`, and
* returns yet another function that takes the last parameter of `f`, and
* returns `f(...args, lastArg)`.
*
* ### Example
*
* ```js
* const add2 = (a, b) => a + b,
* add3 = (a, b, c) => a + b + c;
*
* expect(add2(1, 2)).to.be.equal(3);
* expect(add3(1, 2, 3)).to.be.equal(6);
*
* expect(curry(add2)(1)(2)).to.be.equal(3);
* expect(curry(add3)(1, 2)(3)).to.be.equal(6);
* ```
*/
export function curry<F extends (...allArgs: any) => any>(f: F, ...counts: number[]) {
if (counts.length === 0) {
return (...args: SplitParameters<F>[0]) => (lastArg: SplitParameters<F>[1]) => {
return f(...args, lastArg);
};
}
// TODO: review this
let curried: any = f;
for (let i = counts.length - 1; i >= 0; i--) {
const prev = curried,
len = counts[i];
curried = (...args: any[]) => (...newArgs: any[]) => {
const allArgs = args;
let i = 0;
for (; i < newArgs.length && i < len; i++) {
allArgs.push(newArgs[i]);
}
for (; i < len; i++) {
allArgs.push(undefined);
}
return prev(...allArgs);
};
}
return curried;
}
/* eslint-disable max-len */
// In case we need `pipe` for more than 10 functions:
//
// Array.from({ length: 10 }, (_, n) => {
// const lo = n => String.fromCharCode(97 + n),
// hi = n => String.fromCharCode(65 + n);
//
// return `
// /**
// * Returns a function that maps all non-\`undefined\` values
// * through the given function and returns the remaining results.
// */
// export function pipe<${
// Array.from({ length: n + 2 }, (_, i) => hi(i)).join(", ")
// }>(${
// Array.from({ length: n + 1 }, (_, i) => `${lo(i)}: (_: ${hi(i)}) => ${hi(i + 1)} | undefined`).join(", ")
// }): (values: readonly A[]) => ${hi(n + 1)}[];`;
// }).join("\n")
/**
* Returns a function that maps all non-`undefined` values
* through the given function and returns the remaining results.
*/
export function pipe<A, B>(a: (_: A) => B | undefined): (values: readonly A[]) => B[];
/**
* Returns a function that maps all non-`undefined` values
* through the given function and returns the remaining results.
*
* ### Example
*
* ```js
* const doubleNumbers = pipe((n) => typeof n === "number" ? n : undefined,
* (n) => n * 2);
*
* assert.deepStrictEqual(
* doubleNumbers([1, "a", 2, null, 3, {}]),
* [2, 4, 6],
* );
* ```
*/
export function pipe<A, B, C>(a: (_: A) => B | undefined, b: (_: B) => C | undefined): (values: readonly A[]) => C[];
/**
* Returns a function that maps all non-`undefined` values
* through the given function and returns the remaining results.
*/
export function pipe<A, B, C, D>(a: (_: A) => B | undefined, b: (_: B) => C | undefined, c: (_: C) => D | undefined): (values: readonly A[]) => D[];
/**
* Returns a function that maps all non-`undefined` values
* through the given function and returns the remaining results.
*/
export function pipe<A, B, C, D, E>(a: (_: A) => B | undefined, b: (_: B) => C | undefined, c: (_: C) => D | undefined, d: (_: D) => E | undefined): (values: readonly A[]) => E[];
/**
* Returns a function that maps all non-`undefined` values
* through the given function and returns the remaining results.
*/
export function pipe<A, B, C, D, E, F>(a: (_: A) => B | undefined, b: (_: B) => C | undefined, c: (_: C) => D | undefined, d: (_: D) => E | undefined, e: (_: E) => F | undefined): (values: readonly A[]) => F[];
/**
* Returns a function that maps all non-`undefined` values
* through the given function and returns the remaining results.
*/
export function pipe<A, B, C, D, E, F, G>(a: (_: A) => B | undefined, b: (_: B) => C | undefined, c: (_: C) => D | undefined, d: (_: D) => E | undefined, e: (_: E) => F | undefined, f: (_: F) => G | undefined): (values: readonly A[]) => G[];
/**
* Returns a function that maps all non-`undefined` values
* through the given function and returns the remaining results.
*/
export function pipe<A, B, C, D, E, F, G, H>(a: (_: A) => B | undefined, b: (_: B) => C | undefined, c: (_: C) => D | undefined, d: (_: D) => E | undefined, e: (_: E) => F | undefined, f: (_: F) => G | undefined, g: (_: G) => H | undefined): (values: readonly A[]) => H[];
/**
* Returns a function that maps all non-`undefined` values
* through the given function and returns the remaining results.
*/
export function pipe<A, B, C, D, E, F, G, H, I>(a: (_: A) => B | undefined, b: (_: B) => C | undefined, c: (_: C) => D | undefined, d: (_: D) => E | undefined, e: (_: E) => F | undefined, f: (_: F) => G | undefined, g: (_: G) => H | undefined, h: (_: H) => I | undefined): (values: readonly A[]) => I[];
/**
* Returns a function that maps all non-`undefined` values
* through the given function and returns the remaining results.
*/
export function pipe<A, B, C, D, E, F, G, H, I, J>(a: (_: A) => B | undefined, b: (_: B) => C | undefined, c: (_: C) => D | undefined, d: (_: D) => E | undefined, e: (_: E) => F | undefined, f: (_: F) => G | undefined, g: (_: G) => H | undefined, h: (_: H) => I | undefined, i: (_: I) => J | undefined): (values: readonly A[]) => J[];
/**
* Returns a function that maps all non-`undefined` values
* through the given function and returns the remaining results.
*/
export function pipe<A, B, C, D, E, F, G, H, I, J, K>(a: (_: A) => B | undefined, b: (_: B) => C | undefined, c: (_: C) => D | undefined, d: (_: D) => E | undefined, e: (_: E) => F | undefined, f: (_: F) => G | undefined, g: (_: G) => H | undefined, h: (_: H) => I | undefined, i: (_: I) => J | undefined, j: (_: J) => K | undefined): (values: readonly A[]) => K[];
/* eslint-enable max-len */
export function pipe(...functions: ((_: unknown) => unknown)[]) {
return (values: readonly unknown[]) => {
const results = [],
vlen = values.length,
flen = functions.length;
for (let i = 0; i < vlen; i++) {
let value = values[i];
for (let j = 0; value !== undefined && j < flen; j++) {
value = functions[j](value);
}
if (value !== undefined) {
results.push(value);
}
}
return results;
};
}
/**
* Same as `pipe`, but also works with async functions.
*/
export function pipeAsync(...functions: ((_: unknown) => unknown)[]) {
return async (values: readonly unknown[]) => {
const results = [],
vlen = values.length,
flen = functions.length;
for (let i = 0; i < vlen; i++) {
let value = values[i];
for (let j = 0; value !== undefined && j < flen; j++) {
value = await functions[j](value);
}
if (value !== undefined) {
results.push(value);
}
}
return results;
};
}

17
src/api/history.ts Normal file
View File

@ -0,0 +1,17 @@
import * as vscode from "vscode";
import { Context } from "./context";
/**
* Un-does the last action.
*/
export function undo() {
return Context.wrap(vscode.commands.executeCommand("undo"));
}
/**
* Re-does the last action.
*/
export function redo() {
return Context.wrap(vscode.commands.executeCommand("redo"));
}

71
src/api/index.ts Normal file
View File

@ -0,0 +1,71 @@
import * as vscode from "vscode";
export * from "./clipboard";
export * from "./context";
export * from "./edit";
export * from "./edit/linewise";
export * from "./errors";
export * from "./functional";
export * from "./history";
export * from "./keybindings";
export * from "./lines";
export * from "./menu";
export * from "./modes";
export * from "./positions";
export * from "./prompt";
export * from "./registers";
export * from "./run";
export * from "./search";
export * from "./search/lines";
export * from "./search/move";
export * from "./search/move-to";
export * from "./search/pairs";
export * from "./selections";
/**
* Direction of an operation.
*/
export const enum Direction {
/**
* Forward direction (`1`).
*/
Forward = 1,
/**
* Backward direction (`-1`).
*/
Backward = -1,
}
/**
* Behavior of a shift.
*/
export const enum Shift {
/**
* Jump to the position.
*/
Jump,
/**
* Select to the position.
*/
Select,
/**
* Extend to the position.
*/
Extend,
}
export const Forward = Direction.Forward,
Backward = Direction.Backward,
Jump = Shift.Jump,
Select = Shift.Select,
Extend = Shift.Extend;
/**
* Returns the module exported by the extension with the given identifier.
*/
export function extension<T>(extensionId: string) {
return vscode.extensions.getExtension<T>(extensionId)?.exports;
}

View File

@ -0,0 +1,11 @@
import { Builder } from "../../../meta";
export async function build(builder: Builder) {
const modules = await builder.getCommandModules(),
keybindings = modules.flatMap((module) => module.keybindings),
keybindingsCode = JSON.stringify(keybindings, undefined, 2)
.replace(/"(\w+)":/g, "$1:")
.replace(/([0-9a-z}"])$/gm, "$1,");
return `\nconst builtinKeybindings = ${keybindingsCode};\n`;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,7 @@
// TODO: API for generating JSON for keybindings
/**
* API for generating VS Code-compatible keybindings.
*/
export namespace Keybindings {
}

View File

@ -4,471 +4,471 @@
// Therefore, some commands were moved around to make them available
// in a 'natural' way.
[
export const overrides = [
{
"key": "ctrl+g",
"command": "workbench.action.gotoLine"
"command": "workbench.action.gotoLine",
},
{
"key": "ctrl+g",
"command": "-workbench.action.gotoLine"
"command": "-workbench.action.gotoLine",
},
{
"key": "1",
"command": "dance.selections.align",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "shift+0",
"command": "dance.count.0",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "0",
"command": "-dance.count.0",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "shift+1",
"command": "dance.count.1",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "1",
"command": "-dance.count.1",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "shift+2",
"command": "dance.count.2",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "2",
"command": "-dance.count.2",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "shift+3",
"command": "dance.count.3",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "3",
"command": "-dance.count.3",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "shift+4",
"command": "dance.count.4",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "4",
"command": "-dance.count.4",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "shift+5",
"command": "dance.count.5",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "5",
"command": "-dance.count.5",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "shift+6",
"command": "dance.count.6",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "6",
"command": "-dance.count.6",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "shift+7",
"command": "dance.count.7",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "7",
"command": "-dance.count.7",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "shift+8",
"command": "dance.count.8",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "8",
"command": "-dance.count.8",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "shift+9",
"command": "dance.count.9",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "9",
"command": "-dance.count.9",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "oem_102",
"command": "dance.deindent",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "shift+oem_102",
"command": "dance.indent",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "unknown",
"command": "-dance.selections.align",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "unknown",
"command": "-dance.deindent",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "unknown",
"command": "-dance.indent",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "oem_1",
"command": "dance.pipe.filter",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "shift+4",
"command": "-dance.pipe.filter",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "oem_8",
"command": "dance.pipe.append",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "shift+1",
"command": "-dance.pipe.append",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "alt+oem_8",
"command": "dance.pipe.prepend",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "shift+alt+1",
"command": "-dance.pipe.prepend",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "shift+alt+oem_8",
"command": "dance.pipe.ignore",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "shift+alt+oem_5",
"command": "-dance.pipe.ignore",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "shift+oem_8",
"command": "dance.pipe.replace",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "shift+oem_5",
"command": "-dance.pipe.replace",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "alt+1",
"command": "dance.selections.align.copy",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "alt+unknown",
"command": "-dance.selections.align.copy",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "shift+alt+oem_102",
"command": "dance.indent.withEmpty",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "alt+unknown",
"command": "-dance.indent.withEmpty",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "alt+oem_102",
"command": "dance.deindent.further",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "alt+unknown",
"command": "-dance.deindent.further",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "shift+alt+oem_5",
"command": "dance.objects.selectToEnd.extend.inner",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "shift+alt+unknown",
"command": "-dance.objects.selectToEnd.extend.inner",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "shift+alt+oem_3",
"command": "dance.objects.selectToStart.extend.inner",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "shift+alt+unknown",
"command": "-dance.objects.selectToStart.extend.inner",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "shift+oem_5",
"command": "dance.objects.selectToEnd.extend",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "shift+unknown",
"command": "-dance.objects.selectToEnd.extend",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "shift+oem_3",
"command": "dance.objects.selectToStart.extend",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "shift+unknown",
"command": "-dance.objects.selectToStart.extend",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "alt+oem_period",
"command": "dance.selections.flip",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "alt+oem_1",
"command": "-dance.selections.flip",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "shift+alt+oem_period",
"command": "dance.selections.forward",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "shift+alt+oem_1",
"command": "-dance.selections.forward",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "alt+8",
"command": "dance.selections.merge",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "alt+unknown",
"command": "-dance.selections.merge",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "oem_period",
"command": "dance.selections.reduce",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "oem_1",
"command": "-dance.selections.reduce",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "shift+oem_period",
"command": "dance.repeat.insert",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "oem_period",
"command": "-dance.repeat.insert",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "oem_4",
"command": "dance.rotate",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "unknown",
"command": "-dance.rotate",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "5",
"command": "dance.rotate.backwards",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "unknown",
"command": "-dance.rotate.backwards",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "alt+oem_4",
"command": "dance.rotate.content",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "alt+unknown",
"command": "-dance.rotate.content",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "alt+5",
"command": "dance.rotate.content.backwards",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "alt+unknown",
"command": "-dance.rotate.content.backwards",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "oem_2",
"command": "dance.search",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "unknown",
"command": "-dance.search",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "shift+oem_2",
"command": "dance.search.extend",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "shift+unknown",
"command": "-dance.search.extend",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "alt+oem_2",
"command": "dance.search.backwards",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "alt+unknown",
"command": "-dance.search.backwards",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "shift+alt+oem_2",
"command": "dance.search.backwards.extend",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "shift+alt+unknown",
"command": "-dance.search.backwards.extend",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "3",
"command": "dance.registers.select",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "unknown",
"command": "-dance.registers.select",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "alt+oem_5",
"command": "dance.objects.selectToEnd.inner",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "alt+unknown",
"command": "-dance.objects.selectToEnd.inner",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "unknown",
"command": "-dance.toUpperCase",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "oem_3",
"command": "-dance.toLowerCase",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "oem_3",
"command": "dance.objects.selectToStart",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "unknown",
"command": "-dance.objects.selectToStart",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "oem_5",
"command": "dance.objects.selectToEnd",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "unknown",
"command": "-dance.objects.selectToEnd",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "alt+oem_3",
"command": "dance.objects.selectToStart.inner",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "alt+unknown",
"command": "-dance.objects.selectToStart.inner",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "oem_7",
"command": "dance.toLowerCase"
"command": "dance.toLowerCase",
},
{
"key": "shift+oem_7",
"command": "dance.toUpperCase"
"command": "dance.toUpperCase",
},
{
"key": "alt+oem_7",
"command": "dance.swapCase",
"when": "editorTextFocus && dance.mode == 'normal'"
"when": "editorTextFocus && dance.mode == 'normal'",
},
{
"key": "alt+oem_3",
"command": "-dance.swapCase",
"when": "editorTextFocus && dance.mode == 'normal'"
}
]
"when": "editorTextFocus && dance.mode == 'normal'",
},
];

180
src/api/lines.ts Normal file
View File

@ -0,0 +1,180 @@
import * as vscode from "vscode";
import { Context } from "./context";
import { assert } from "./errors";
/**
* Returns the 0-based number of the first visible line in the current editor.
*/
export function firstVisibleLine(editor = Context.current.editor) {
return editor.visibleRanges[0].start.line;
}
/**
* Returns the 0-based number of the middle visible line in the current editor.
*/
export function middleVisibleLine(editor = Context.current.editor) {
const range = editor.visibleRanges[0];
return ((range.start.line + range.end.line) / 2) | 0;
}
/**
* Returns the 0-based number of the last visible line in the current editor.
*/
export function lastVisibleLine(editor = Context.current.editor) {
return editor.visibleRanges[0].end.line;
}
export namespace Lines {
/**
* Returns the text contents of the given line.
*/
export function text(line: number, document = Context.current.document) {
return document.lineAt(line).text;
}
/**
* Returns the length of the given line.
*/
export function length(line: number, document = Context.current.document) {
return document.lineAt(line).text.length;
}
/**
* Returns whether the given line is empty.
*/
export function isEmpty(line: number, document = Context.current.document) {
return length(line, document) === 0;
}
/**
* Returns the given line number, possibly modified to fit in the current
* document.
*/
export function clamp(line: number, document?: vscode.TextDocument) {
if (line < 0) {
return 0;
}
const lastLine = (document ?? Context.current.document).lineCount - 1;
if (line > lastLine) {
return lastLine;
}
return line;
}
}
function diffAddedByTabs(text: string, editor: Pick<vscode.TextEditor, "options">) {
const tabSize = editor.options.tabSize as number;
let total = 0;
for (const ch of text) {
if (ch === "\t") {
total += tabSize - 1;
}
}
return total;
}
/**
* Returns the position corresponding to the character at the given position,
* taking into account tab characters that precede it.
*/
export function column(
position: vscode.Position,
editor?: Pick<vscode.TextEditor, "document" | "options">,
): vscode.Position;
/**
* Returns the render column corresponding to the specified character in the
* specified line, taking into account tab characters that precede it.
*/
export function column(
line: number,
character: number,
editor?: Pick<vscode.TextEditor, "document" | "options">,
): number;
export function column(
line: number | vscode.Position,
character?: number | Pick<vscode.TextEditor, "document" | "options">,
editor?: Pick<vscode.TextEditor, "document" | "options">,
) {
if (typeof line === "number") {
editor ??= Context.current.editor;
const text = editor.document.lineAt(line).text.slice(0, character as number);
return text.length + diffAddedByTabs(text, editor);
}
editor ??= Context.current.editor;
const text = editor.document.lineAt(line.line).text.slice(0, line.character);
return new vscode.Position(line.line, text.length + diffAddedByTabs(text, editor));
}
export namespace column {
/**
* Returns the `vscode.Position`-compatible position for the given position.
* Reverses the diff added by `column`.
*/
export function character(
position: vscode.Position,
editor?: Pick<vscode.TextEditor, "document" | "options">,
): vscode.Position;
/**
* Returns the `vscode.Position`-compatible character for the given column.
* Reverses the diff added by `column`.
*/
export function character(
line: number,
character: number,
editor?: Pick<vscode.TextEditor, "document" | "options">,
): number;
export function character(
line: number | vscode.Position,
character?: number | Pick<vscode.TextEditor, "document" | "options">,
editor?: Pick<vscode.TextEditor, "document" | "options">,
) {
if (typeof line === "number") {
editor ??= Context.current.editor;
const text = editor.document.lineAt(line).text.slice(0, character as number);
return text.length - diffAddedByTabs(text, editor);
}
editor ??= Context.current.editor;
const text = editor.document.lineAt(line.line).text.slice(0, line.character);
return new vscode.Position(line.line, text.length - diffAddedByTabs(text, editor));
}
}
/**
* Same as `Lines.length`, but also increases the count according to tab
* characters so that the result matches the rendered view.
*
* @see Lines.length
*/
export function columns(
line: number | vscode.Position,
editor: Pick<vscode.TextEditor, "document" | "options"> = Context.current.editor,
): number {
if (typeof line !== "number") {
line = line.line;
}
const text = editor.document.lineAt(line).text;
return text.length + diffAddedByTabs(text, editor);
}

173
src/api/menu.ts Normal file
View File

@ -0,0 +1,173 @@
import * as vscode from "vscode";
import { Context, prompt } from ".";
export interface Menu {
readonly items: Menu.Items;
}
export namespace Menu {
export interface Items {
[keys: string]: Item;
}
export interface Item {
readonly text: string;
readonly command: string;
readonly args?: any[];
}
}
/**
* Validates the given menu and returns a list of strings representing errors
* with the given menu. If that list is empty, the menu is valid and can be
* used.
*/
export function validateMenu(menu: Menu) {
if (typeof menu !== "object" || menu === null) {
return ["menu must be an object"];
}
if (typeof menu.items !== "object" || Object.keys(menu.items ?? {}).length === 0) {
return ['menu must have an subobject "items" with at least two entries.'];
}
const seenKeyCodes = new Map<number, string>(),
errors = [] as string[];
for (const key in menu.items) {
const item = menu.items[key],
itemDisplay = JSON.stringify(key);
if (typeof item !== "object" || item === null) {
errors.push(`item ${itemDisplay} must be an object.`);
continue;
}
if (typeof item.text !== "string" || item.text.length === 0) {
errors.push(`item ${itemDisplay} must have a non-empty "text" property.`);
continue;
}
if (typeof item.command !== "string" || item.command.length === 0) {
errors.push(`item ${itemDisplay} must have a non-empty "command" property.`);
continue;
}
if (key.length === 0) {
errors.push(`item ${itemDisplay} must be a non-empty string key.`);
continue;
}
for (let i = 0; i < key.length; i++) {
const keyCode = key.charCodeAt(i),
prevKey = seenKeyCodes.get(keyCode);
if (prevKey) {
errors.push(`menu has duplicate key '${key[i]}' (specified by '${prevKey}' and '${key}').`);
continue;
}
seenKeyCodes.set(keyCode, key);
}
}
return errors;
}
/**
* Shows the given menu to the user, awaiting a choice.
*/
export async function showMenu(
menu: Menu,
additionalArgs: readonly any[] = [],
prefix?: string,
) {
const entries = Object.entries(menu.items);
const items = entries.map((x) => [x[0], x[1].text] as const);
const choice = await prompt.one(items);
if (typeof choice === "string") {
if (prefix !== undefined) {
await vscode.commands.executeCommand("default:type", { text: prefix + choice });
}
return;
}
const pickedItem = entries[choice][1],
args = mergeArgs(pickedItem.args, additionalArgs);
return Context.WithoutActiveEditor.wrap(
vscode.commands.executeCommand(pickedItem.command, ...args),
);
}
export namespace showMenu {
/**
* Shows the menu with the given name.
*/
export function byName(
menuName: string,
additionalArgs: readonly any[] = [],
prefix?: string,
) {
const menu = Context.WithoutActiveEditor.current.extension.menus.get(menuName);
if (menu === undefined) {
return Promise.reject(new Error(`menu ${JSON.stringify(menuName)} does not exist`));
}
return showMenu(menu, additionalArgs, prefix);
}
}
/**
* Shows the given menu to the user, not dismissing it when a key is pressed.
*/
export async function showLockedMenu(
menu: Menu,
additionalArgs: readonly any[] = [],
) {
const entries = Object.entries(menu.items),
items = entries.map(([keys, item]) =>
[keys, item.text, () =>
vscode.commands.executeCommand(
item.command, ...mergeArgs(item.args, additionalArgs))] as const);
await prompt.one.locked(items);
}
export namespace showLockedMenu {
/**
* Shows the menu with the given name.
*/
export function byName(
menuName: string,
additionalArgs: readonly any[] = [],
) {
const menu = Context.WithoutActiveEditor.current.extension.menus.get(menuName);
if (menu === undefined) {
return Promise.reject(new Error(`menu ${JSON.stringify(menuName)} does not exist`));
}
return showLockedMenu(menu, additionalArgs);
}
}
function mergeArgs(args: readonly any[] | undefined, additionalArgs: readonly any[]) {
if (args == null) {
return additionalArgs;
} else if (additionalArgs.length > 0) {
return args.length > additionalArgs.length
? args.map((arg, i) =>
i < additionalArgs.length && additionalArgs[i]
? Object.assign({}, additionalArgs[i], arg)
: arg)
: additionalArgs.map((arg, i) =>
i < args.length ? Object.assign({}, arg, args[i]) : arg);
} else {
return args;
}
}

38
src/api/modes.ts Normal file
View File

@ -0,0 +1,38 @@
import { Context } from "./context";
/**
* Switches to the mode with the given name.
*/
export function toMode(modeName: string): Thenable<void>;
/**
* Temporarily switches to the mode with the given name.
*/
export function toMode(modeName: string, count: number): Thenable<void>;
export function toMode(modeName: string, count?: number) {
const context = Context.current,
mode = context.extension.modes.get(modeName);
if (mode === undefined || mode.isPendingDeletion) {
throw new Error(`mode ${JSON.stringify(modeName)} does not exist`);
}
if (!count) {
return context.switchToMode(mode);
}
const editorState = context.getState();
const disposable = context.extension
.createAutoDisposable()
.addDisposable(context.extension.editors.onModeDidChange((editorState) => {
if (editorState.editor === context.editor && editorState.mode !== mode) {
disposable.dispose();
}
}))
.disposeOnEvent(editorState.onVisibilityDidChange);
// TODO: watch document changes and command executions
return context.switchToMode(mode);
}

167
src/api/positions.ts Normal file
View File

@ -0,0 +1,167 @@
import * as vscode from "vscode";
import { Direction } from ".";
import { Context } from "./context";
/**
* Returns the position right after the given position, or `undefined` if
* `position` is the last position of the document.
*/
export function nextPosition(position: vscode.Position, document?: vscode.TextDocument) {
document ??= Context.current.document;
const line = position.line,
character = position.character,
textLineLen = document.lineAt(line).text.length;
if (character < textLineLen) {
return new vscode.Position(line, character + 1);
}
if (line === document.lineCount - 1) {
return undefined;
}
return new vscode.Position(line + 1, 0);
}
/**
* Returns the position right before the given position, or `undefined` if
* `position` is the first position of the document.
*/
export function previousPosition(position: vscode.Position, document?: vscode.TextDocument) {
const line = position.line,
character = position.character;
if (character > 0) {
return new vscode.Position(line, character - 1);
}
if (line === 0) {
return undefined;
}
return new vscode.Position(
line - 1,
(document ?? Context.current.document).lineAt(line - 1).text.length,
);
}
/**
* Returns the position at a given (possibly negative) offset from the given
* position, or `undefined` if such a position would go out of the bounds of the
* document.
*/
export function offsetPosition(
position: vscode.Position,
by: number,
document?: vscode.TextDocument,
) {
if (by === 0) {
return position;
}
if (by === 1) {
return nextPosition(position, document);
}
if (by === -1) {
return previousPosition(position, document);
}
document ??= Context.current.document;
const offset = document.offsetAt(position) + by;
if (offset === -1) {
return undefined;
}
return document.positionAt(document.offsetAt(position) + by);
}
export namespace offsetPosition {
/**
* Same as `offsetPosition`, but clamps to document edges.
*/
export function orEdge(
position: vscode.Position,
by: number,
document?: vscode.TextDocument,
) {
const result = offsetPosition(position, by, document);
if (result === undefined) {
return by < 0 ? Positions.zero : Positions.last(document);
}
return result;
}
}
/**
* Operations on `vscode.Position`s.
*/
export namespace Positions {
export const next = nextPosition,
previous = previousPosition,
offset = offsetPosition;
/**
* The (0, 0) position.
*/
export const zero = new vscode.Position(0, 0);
/**
* Returns the last position of the given document.
*/
export function last(document = Context.current.document) {
return document.lineAt(document.lineCount - 1).rangeIncludingLineBreak.end;
}
/**
* Returns the position at the given line and character.
*/
export function at(line: number, character: number) {
return new vscode.Position(line, character);
}
/**
* Returns the position at the start of the given line.
*/
export function lineStart(line: number) {
return new vscode.Position(line, 0);
}
/**
* Returns the position at the first non-blank character of the given line, or
* the end of the line if the line is fully blank.
*/
export function nonBlankLineStart(line: number, document = Context.current.document) {
return new vscode.Position(line, document.lineAt(line).firstNonWhitespaceCharacterIndex);
}
/**
* Returns the position at the end of the given line.
*/
export function lineEnd(line: number, document = Context.current.document) {
return new vscode.Position(line, document.lineAt(line).text.length);
}
/**
* Returns the position after the end of the given line, i.e. the first
* position of the next line.
*/
export function lineBreak(line: number, document = Context.current.document) {
return line + 1 === document.lineCount ? lineEnd(line, document) : lineStart(line + 1);
}
/**
* Returns the last position of the current document when going in the given
* direction. If `Backward`, this is `Positions.zero`. If `Forward`, this is
* `Positions.last(document)`.
*/
export function edge(direction: Direction, document?: vscode.TextDocument) {
return direction === Direction.Backward ? Positions.zero : last(document);
}
}

426
src/api/prompt.ts Normal file
View File

@ -0,0 +1,426 @@
import * as vscode from "vscode";
import { Context } from "../api";
import { CancellationError } from "./errors";
/**
* Displays a prompt to the user.
*/
export function prompt(
opts: vscode.InputBoxOptions,
context = Context.WithoutActiveEditor.current,
) {
return context.wrap(vscode.window.showInputBox(opts, context.cancellationToken)
.then((v) => {
if (v === undefined) {
const reason = context.cancellationToken?.isCancellationRequested
? CancellationError.Reason.CancellationToken
: CancellationError.Reason.PressedEscape;
return Promise.reject(new CancellationError(reason));
}
return v;
}));
}
export namespace prompt {
type RegExpFlag = "m" | "u" | "s" | "y" | "i" | "g";
type RegExpFlags = RegExpFlag
| `${RegExpFlag}${RegExpFlag}`
| `${RegExpFlag}${RegExpFlag}${RegExpFlag}`
| `${RegExpFlag}${RegExpFlag}${RegExpFlag}${RegExpFlag}`;
/**
* Returns `vscode.InputBoxOptions` that only validate if a number in a given
* range is entered.
*/
export function numberOpts(
opts: { integer?: boolean; range?: [number, number] } = {},
): vscode.InputBoxOptions {
return {
validateInput(input) {
const n = +input;
if (isNaN(n)) {
return "Invalid number.";
}
if (opts.range && (n < opts.range[0] || n > opts.range[1])) {
return `Number out of range ${JSON.stringify(opts.range)}.`;
}
if (opts.integer && (n | 0) !== n) {
return `Number must be an integer.`;
}
return;
},
};
}
/**
* Equivalent to `+await prompt(numberOpts(), context)`.
*/
export function number(
opts: Parameters<typeof numberOpts>[0],
context = Context.WithoutActiveEditor.current,
) {
return prompt(numberOpts(opts), context).then((x) => +x);
}
/**
* Returns `vscode.InputBoxOptions` that only validate if a valid ECMAScript
* regular expression is entered.
*/
export function regexpOpts(flags: RegExpFlags): vscode.InputBoxOptions {
return {
prompt: "Regular expression",
validateInput(input) {
if (input.length === 0) {
return "RegExp cannot be empty";
}
try {
new RegExp(input, flags);
return undefined;
} catch {
return "invalid RegExp";
}
},
};
}
/**
* Equivalent to `new RegExp(await prompt(regexpOpts(flags), context), flags)`.
*/
export function regexp(
flags: RegExpFlags,
context = Context.WithoutActiveEditor.current,
) {
return prompt(regexpOpts(flags), context).then((x) => new RegExp(x, flags));
}
/**
* Prompts the user for a result interactively.
*/
export function interactive<T>(
compute: (input: string) => T | Thenable<T>,
reset: () => void,
options: vscode.InputBoxOptions = {},
interactive: boolean = true,
): Thenable<T> {
let result: T;
const validateInput = options.validateInput;
if (!interactive) {
return prompt(options).then((value) => compute(value));
}
return prompt({
...options,
async validateInput(input) {
const validationError = await validateInput?.(input);
if (validationError) {
return validationError;
}
try {
result = await compute(input);
return;
} catch (e) {
return `${e}`;
}
},
}).then(
() => result,
(err) => {
reset();
throw err;
},
);
}
export type ListPair = readonly [string, string];
/**
* Prompts the user to choose one item among a list of items, and returns the
* index of the item that was picked.
*/
export function one(
items: readonly ListPair[],
init?: (quickPick: vscode.QuickPick<vscode.QuickPickItem>) => void,
context = Context.WithoutActiveEditor.current,
) {
return promptInList(false, items, init ?? (() => {}), context.cancellationToken);
}
export namespace one {
/**
* Prompts the user for actions in a menu, only hiding it when a
* cancellation is requested or `Escape` pressed.
*/
export function locked(
items: readonly (readonly [string, string, () => void])[],
init?: (quickPick: vscode.QuickPick<vscode.QuickPickItem>) => void,
cancellationToken = Context.WithoutActiveEditor.current.cancellationToken,
) {
const itemsKeys = items.map(([k, _]) => k.includes(", ") ? k.split(", ") : [...k]);
return new Promise<void>((resolve, reject) => {
const quickPick = vscode.window.createQuickPick(),
quickPickItems = [] as vscode.QuickPickItem[];
let isCaseSignificant = false;
for (let i = 0; i < items.length; i++) {
const [label, description] = items[i];
quickPickItems.push({ label, description });
isCaseSignificant = isCaseSignificant || label.toLowerCase() !== label;
}
quickPick.items = quickPickItems;
quickPick.placeholder = "Press one of the below keys.";
const subscriptions = [
quickPick.onDidChangeValue((rawKey) => {
quickPick.value = "";
// This causes the menu to disappear and reappear for a frame, but
// without this the shown items don't get refreshed after the value
// change above.
quickPick.items = quickPickItems;
let key = rawKey;
if (!isCaseSignificant) {
key = key.toLowerCase();
}
const index = itemsKeys.findIndex((x) => x.includes(key));
if (index !== -1) {
items[index][2]();
}
}),
quickPick.onDidHide(() => {
subscriptions.splice(0).forEach((s) => s.dispose());
resolve();
}),
quickPick.onDidAccept(() => {
subscriptions.splice(0).forEach((s) => s.dispose());
const picked = quickPick.selectedItems[0];
try {
items.find((x) => x[1] === picked.description)![2]();
} finally {
resolve();
}
}),
cancellationToken?.onCancellationRequested(() => {
subscriptions.splice(0).forEach((s) => s.dispose());
reject(new CancellationError(CancellationError.Reason.CancellationToken));
}),
quickPick,
];
init?.(quickPick);
quickPick.show();
});
}
}
/**
* Prompts the user to choose many items among a list of items, and returns a
* list of indices of picked items.
*/
export function many(
items: readonly ListPair[],
init?: (quickPick: vscode.QuickPick<vscode.QuickPickItem>) => void,
context = Context.WithoutActiveEditor.current,
) {
return promptInList(true, items, init ?? (() => {}), context.cancellationToken);
}
}
/**
* Awaits a keypress from the user and returns the entered key.
*/
export function keypress(context = Context.current): Promise<string> {
if (context.cancellationToken.isCancellationRequested) {
return Promise.reject(new CancellationError(CancellationError.Reason.CancellationToken));
}
const previousMode = context.mode;
return context.switchToMode(context.extension.modes.inputMode).then(() =>
new Promise<string>((resolve, reject) => {
try {
const subscriptions = [
vscode.commands.registerCommand("type", ({ text }: { text: string }) => {
if (subscriptions.length > 0) {
subscriptions.splice(0).forEach((s) => s.dispose());
context.switchToMode(previousMode).then(() => resolve(text));
}
}),
context.cancellationToken.onCancellationRequested(() => {
if (subscriptions.length > 0) {
subscriptions.splice(0).forEach((s) => s.dispose());
context.switchToMode(previousMode)
.then(() => reject(new CancellationError(CancellationError.Reason.PressedEscape)));
}
}),
];
} catch {
reject(new Error("unable to listen to keyboard events; is an extension "
+ 'overriding the "type" command (e.g VSCodeVim)?'));
}
}),
);
}
export namespace keypress {
/**
* Awaits a keypress describing a register and returns the specified register.
*/
export async function forRegister(context = Context.current) {
const firstKey = await keypress(context);
if (firstKey !== " ") {
return context.extension.registers.get(firstKey);
}
const secondKey = await keypress(context);
return context.extension.registers.forDocument(context.document).get(secondKey);
}
}
function promptInList(
canPickMany: true,
items: readonly (readonly [string, string])[],
init: (quickPick: vscode.QuickPick<vscode.QuickPickItem>) => void,
cancellationToken: vscode.CancellationToken,
): Thenable<string | number[]>;
function promptInList(
canPickMany: false,
items: readonly (readonly [string, string])[],
init: (quickPick: vscode.QuickPick<vscode.QuickPickItem>) => void,
cancellationToken: vscode.CancellationToken,
): Thenable<string | number>;
function promptInList(
canPickMany: boolean,
items: readonly (readonly [string, string])[],
init: (quickPick: vscode.QuickPick<vscode.QuickPickItem>) => void,
cancellationToken: vscode.CancellationToken,
): Thenable<string | number | number[]> {
const itemsKeys = items.map(([k, _]) => k.includes(", ") ? k.split(", ") : [...k]);
return new Promise<string | number | number[]>((resolve, reject) => {
const quickPick = vscode.window.createQuickPick(),
quickPickItems = [] as vscode.QuickPickItem[];
let isCaseSignificant = false;
for (let i = 0; i < items.length; i++) {
const [label, description] = items[i];
quickPickItems.push({ label, description });
isCaseSignificant = isCaseSignificant || label.toLowerCase() !== label;
}
quickPick.items = quickPickItems;
quickPick.placeholder = "Press one of the below keys.";
quickPick.canSelectMany = canPickMany;
const subscriptions = [
quickPick.onDidChangeValue((rawKey) => {
if (subscriptions.length === 0) {
return;
}
let key = rawKey;
if (!isCaseSignificant) {
key = key.toLowerCase();
}
const index = itemsKeys.findIndex((x) => x.includes(key));
subscriptions.splice(0).forEach((s) => s.dispose());
if (index === -1) {
return resolve(rawKey);
}
if (canPickMany) {
resolve([index]);
} else {
resolve(index);
}
}),
quickPick.onDidAccept(() => {
if (subscriptions.length === 0) {
return;
}
let picked = quickPick.selectedItems;
if (picked !== undefined && picked.length === 0) {
picked = quickPick.activeItems;
}
subscriptions.splice(0).forEach((s) => s.dispose());
if (picked === undefined) {
return reject(new CancellationError(CancellationError.Reason.PressedEscape));
}
if (canPickMany) {
resolve(picked.map((x) => items.findIndex((item) => item[1] === x.description)));
} else {
resolve(items.findIndex((x) => x[1] === picked[0].description));
}
}),
quickPick.onDidHide(() => {
if (subscriptions.length === 0) {
return;
}
subscriptions.splice(0).forEach((s) => s.dispose());
reject(new CancellationError(CancellationError.Reason.PressedEscape));
}),
cancellationToken?.onCancellationRequested(() => {
if (subscriptions.length === 0) {
return;
}
subscriptions.splice(0).forEach((s) => s.dispose());
reject(new CancellationError(CancellationError.Reason.CancellationToken));
}),
quickPick,
];
init(quickPick);
quickPick.show();
});
}

53
src/api/registers.ts Normal file
View File

@ -0,0 +1,53 @@
import * as vscode from "vscode";
import { Register } from "../state/registers";
import { Context } from "./context";
/**
* Returns the `i`th string in the register with the given name, or `undefined`
* if no value is available.
*/
export function register(name: string, i: number): Thenable<string | undefined>;
/**
* Returns the strings in the register with the given name, or `undefined` if no
* values are available.
*/
export function register(name: string): Thenable<readonly string[] | undefined>;
export function register(name: string, i?: number) {
const register: Register = Context.current.extension.registers.get(name);
register.ensureCanRead();
if (i === undefined) {
return register.get();
}
return register.get().then((values) => values?.[i]);
}
export namespace register {
/**
* Returns the `i`th selection in the register with the given name, or
* `undefined` if no selection is available.
*/
export function selection(name: string, i: number): Thenable<vscode.Selection | undefined>;
/**
* Returns the selections in the register with the given name, or `undefined`
* if no selections are available.
*/
export function selection(name: string): Thenable<readonly vscode.Selection[] | undefined>;
export function selection(name: string, i?: number): any {
const register: Register = Context.current.extension.registers.get(name);
register.ensureCanReadSelections();
if (i === undefined) {
return Promise.resolve(register.getSelections());
}
return Promise.resolve(register.getSelections()?.[i]);
}
}

464
src/api/run.ts Normal file
View File

@ -0,0 +1,464 @@
import * as vscode from "vscode";
import * as api from ".";
import { CommandDescriptor } from "../commands";
import { parseRegExpWithReplacement } from "../utils/regexp";
import { Context } from "./context";
/**
* Runs the given string of JavaScript code.
*/
export function run(string: string, context?: object): Thenable<unknown>;
/**
* Runs the given strings of JavaScript code.
*/
export function run(strings: readonly string[], context?: object): Thenable<unknown[]>;
export function run(strings: string | readonly string[], context: object = {}) {
const isSingleStringArgument = typeof strings === "string";
if (isSingleStringArgument) {
strings = [strings as string];
}
const functions: ((...args: any[]) => Thenable<unknown>)[] = [];
for (const code of strings) {
functions.push(run.compileFunction(code, Object.keys(context)));
}
const parameterValues = run.parameterValues();
if (isSingleStringArgument) {
return Context.WithoutActiveEditor.wrap(
functions[0](...parameterValues, ...Object.values(context)),
);
}
const promises: Thenable<unknown>[] = [],
contextValues = Object.values(context);
for (const func of functions) {
promises.push(func(...parameterValues, ...contextValues));
}
return Context.WithoutActiveEditor.wrap(Promise.all(promises));
}
const cachedParameterNames = [] as string[],
cachedParameters = [] as unknown[];
function ensureCacheIsPopulated() {
if (cachedParameterNames.length > 0) {
return;
}
for (const name in api) {
cachedParameterNames.push(name);
cachedParameters.push((api as any)[name]);
}
cachedParameterNames.push("vscode");
cachedParameters.push(vscode);
Object.freeze(cachedParameterNames);
Object.freeze(cachedParameters);
}
export namespace run {
/**
* Returns the parameter names given to dynamically run functions.
*/
export function parameterNames() {
ensureCacheIsPopulated();
return cachedParameterNames as readonly string[];
}
/**
* Returns the parameter values given to dynamically run functions.
*/
export function parameterValues() {
ensureCacheIsPopulated();
return cachedParameters as readonly unknown[];
}
let canRunArbitraryCode = true;
/**
* Disables usage of the `compileFunction` and `run` functions, preventing the
* execution of arbitrary user inputs.
*
* For security purposes, execution cannot be re-enabled after calling this
* function.
*/
export function disable() {
canRunArbitraryCode = false;
}
interface CompiledFunction {
(...args: any[]): Thenable<unknown>;
}
const AsyncFunction: new (...names: string[]) => CompiledFunction =
async function () {}.constructor as any,
functionCache = new Map<string, CachedFunction>();
type CachedFunction = [function: CompiledFunction, lastAccessTimestamp: number];
/**
* A few common inputs.
*/
const safeExpressions = [
/^(\$\$?|[in]|\d+) *([=!]==?|[<>]=?|&{1,2}|\|{1,2}) *(\$\$?|[in]|\d+)$/,
/^i( + 1)?$/,
/^`\${await register\(["']\w+["'], *[i0-9]\)}` !== ["']false["']$/,
];
/**
* Compiles the given JavaScript code into a function.
*/
export function compileFunction(code: string, additionalParameterNames: readonly string[] = []) {
if (!canRunArbitraryCode && !safeExpressions.some((re) => re.test(code))) {
throw new Error("execution of arbitrary code is disabled");
}
const cacheId = additionalParameterNames.join(";") + code,
cached = functionCache.get(cacheId);
if (cached !== undefined) {
cached[1] = Date.now();
return cached[0];
}
let func: CompiledFunction;
try {
// Wrap code in block to allow shadowing of parameters.
func = new AsyncFunction(...parameterNames(), ...additionalParameterNames, `{\n${code}\n}`);
} catch (e) {
throw new Error(`cannot parse function body: ${code}: ${e}`);
}
functionCache.set(cacheId, [func, Date.now()]);
return func;
}
/**
* Removes all functions that were not used in the last n milliseconds from
* the cache.
*/
export function clearCache(olderThanMs: number): void;
/**
* Removes all functions that were not used in the last 5 minutes from the
* cache.
*/
export function clearCache(): void;
export function clearCache(olderThanMs = 1000 * 60 * 5) {
if (olderThanMs === 0) {
return functionCache.clear();
}
const olderThan = Date.now() - olderThanMs,
toDelete = [] as string[];
for (const [code, value] of functionCache) {
if (value[1] < olderThan) {
toDelete.push(code);
}
}
for (const code of toDelete) {
functionCache.delete(code);
}
}
}
/**
* Runs the VS Code command with the given identifier and optional arguments.
*/
export function command(commandName: string, ...args: readonly any[]): Thenable<any> {
return commands([commandName, ...args]).then((x) => x[0]);
}
/**
* Runs the VS Code commands with the given identifiers and optional arguments.
*/
export async function commands(...commands: readonly command.Any[]): Promise<any[]> {
const extension = Context.WithoutActiveEditor.current.extension,
batches = [] as ([CommandDescriptor, any][] | [string, any])[],
currentBatch = [] as [CommandDescriptor, any][];
// Build and validate commands.
for (let i = 0, len = commands.length; i < len; i++) {
let commandName: string,
commandArguments: any;
const command = commands[i];
if (typeof command === "string") {
commandName = command;
commandArguments = undefined;
} else if (Array.isArray(command)) {
commandName = command[0];
commandArguments = command.slice(1);
if (typeof commandName !== "string") {
throw new Error("the first element of a command tuple must be a command name");
}
} else if (typeof command === "object" && command !== null) {
commandName = (command as command.Command).command;
commandArguments = (command as command.Command).args;
if (typeof commandName !== "string") {
throw new Error("the \"command\" property of a command object must be a command name");
}
} else {
throw new Error(
"commands must be command names, {command: string, args: any} objects or arrays",
);
}
if (commandName.startsWith(".")) {
commandName = `dance${commandName}`;
}
if (commandName.startsWith("dance.")) {
const descriptor = extension.commands[commandName];
if (descriptor === undefined) {
throw new Error(`command ${JSON.stringify(commandName)} does not exist`);
}
const argument = Array.isArray(commandArguments) ? commandArguments[0] : commandArguments;
currentBatch.push([descriptor, argument]);
} else {
if (currentBatch.length > 0) {
batches.push(currentBatch.splice(0));
}
batches.push([commandName, commandArguments]);
}
}
if (currentBatch.length > 0) {
batches.push(currentBatch);
}
// Execute all commands.
const results = [];
for (const batch of batches) {
if (typeof batch[0] === "string") {
results.push(await vscode.commands.executeCommand(batch[0], batch[1]));
} else {
const context = Context.WithoutActiveEditor.current;
for (const pair of batch as [CommandDescriptor, any][]) {
const [descriptor, argument] = pair,
ownedArgument = pair[1] = Object.assign({}, argument);
if (ownedArgument.try) {
delete ownedArgument.try;
try {
results.push(await descriptor.handler(context, ownedArgument));
} catch {
results.push(undefined);
}
} else {
results.push(await descriptor.handler(context, ownedArgument));
}
}
}
}
const recorder = extension.recorder;
for (const batch of batches) {
if (typeof batch[0] === "string") {
recorder.recordExternalCommand(batch[0], batch[1]);
} else {
for (const [descriptor, argument] of batch as [CommandDescriptor, any][]) {
recorder.recordCommand(descriptor, argument);
}
}
}
return results;
}
export namespace command {
/**
* A tuple given to `command`.
*/
export type Tuple = readonly [commandId: string, ...args: any[]];
/**
* An object given to `command`.
*/
export interface Command {
readonly command: string;
readonly args?: any;
}
export type Any = string | Tuple | Command;
}
let canExecuteArbitraryCommands = true;
/**
* Executes a shell command.
*/
export function execute(
command: string,
input?: string,
cancellationToken = Context.WithoutActiveEditor.current.cancellationToken,
) {
if (!canExecuteArbitraryCommands) {
return Context.WithoutActiveEditor.wrap(
Promise.reject(new Error("execution of arbitrary commands is disabled")),
);
}
return Context.WithoutActiveEditor.wrap(import("child_process").then((cp) =>
new Promise<{ readonly val: string } | { readonly err: string }>((resolve) => {
const shell = getShell() ?? true,
child = cp.spawn(command, { shell, stdio: "pipe" });
let stdout = "",
stderr = "";
const disposable = cancellationToken.onCancellationRequested(() => {
child.kill("SIGINT");
});
child.stdout.on("data", (chunk: Buffer) => (stdout += chunk.toString("utf-8")));
child.stderr.on("data", (chunk: Buffer) => (stderr += chunk.toString("utf-8")));
child.stdin.end(input, "utf-8");
child.once("error", (err) => {
disposable.dispose();
resolve({ err: err.message });
});
child.once("exit", (code) => {
disposable.dispose();
code === 0
? resolve({ val: stdout.trimRight() })
: resolve({
err: `Command exited with error ${code}: ${
stderr.length > 0 ? stderr.trimRight() : "<No error output>"
}`,
});
});
})),
);
}
export namespace execute {
/**
* Disables usage of the `execute` function, preventing the execution of
* arbitrary user commands.
*
* For security purposes, execution cannot be re-enabled after calling this
* function.
*/
export function disable() {
canExecuteArbitraryCommands = false;
}
}
function getShell() {
let os: string;
switch (process.platform) {
case "cygwin":
case "linux":
os = "linux";
break;
case "darwin":
os = "osx";
break;
case "win32":
os = "windows";
break;
default:
return undefined;
}
const config = vscode.workspace.getConfiguration("terminal");
return config.get<string | null>(`integrated.automationShell.${os}`)
?? process.env.SHELL
?? undefined;
}
/**
* Runs the given string of JavaScript code on the given input, except in two
* cases:
* 1. If the string is a complete RegExp expression, instead its match will be
* returned.
* 2. If the string starts with "#", instead a command will be run.
*/
export function switchRun(string: string, context: { $: string } & Record<string, any>) {
if (string.length === 0) {
// An empty expression is just `undefined`.
return Context.WithoutActiveEditor.wrap(Promise.resolve());
}
if (string[0] === "/") {
// RegExp replace or match.
const [regexp, replacement] = parseRegExpWithReplacement(string);
if (replacement === undefined) {
return Context.WithoutActiveEditor.wrap(Promise.resolve(regexp.exec(context.$)));
}
return Context.WithoutActiveEditor.wrap(Promise.resolve(context.$.replace(regexp, replacement)));
}
if (string[0] === "#") {
// Shell command.
return execute(string.slice(1), context.$);
}
// JavaScript expression.
return run("return " + string, context);
}
export namespace switchRun {
/**
* Validates the given input string. If it is invalid, an exception will be
* thrown.
*/
export function validate(string: string) {
if (string.trim().length === 0) {
throw new Error("the given string cannot be empty");
}
if (string[0] === "/") {
parseRegExpWithReplacement(string);
return;
}
if (string[0] === "#") {
if (string.slice(1).trim().length === 0) {
throw new Error("the given shell command cannot be empty");
}
return;
}
run.compileFunction("return " + string);
}
}

386
src/api/search/index.ts Normal file
View File

@ -0,0 +1,386 @@
import * as vscode from "vscode";
import * as regexp from "../../utils/regexp";
import { Context, Direction } from "..";
import { Positions } from "../positions";
/**
* Searches backward or forward for a pattern starting at the given position.
*
* @see search.backward,search.forward
*/
export function search(
direction: Direction,
re: RegExp,
origin: vscode.Position,
end?: vscode.Position,
) {
return direction === Direction.Backward
? search.backward(re, origin, end)
: search.forward(re, origin, end);
}
export namespace search {
/**
* The type of the result of a search: a `[startPosition, match]` pair if the
* search succeeded, and `undefined` otherwise.
*/
export type Result = [vscode.Position, RegExpMatchArray] | undefined;
/**
* Searches backward for a pattern starting at the given position.
*
* ### Example
*
* ```ts
* const [p1, [t1]] = search.backward(/\w/, new vscode.Position(0, 1))!;
*
* assert.deepStrictEqual(p1, new vscode.Position(0, 0));
* assert.strictEqual(t1, "a");
*
* const [p2, [t2]] = search.backward(/\w/, new vscode.Position(0, 2))!;
*
* assert.deepStrictEqual(p2, new vscode.Position(0, 1));
* assert.strictEqual(t2, "b");
*
* const [p3, [t3]] = search.backward(/\w+/, new vscode.Position(0, 2))!;
*
* assert.deepStrictEqual(p3, new vscode.Position(0, 0));
* assert.strictEqual(t3, "ab");
*
* assert.strictEqual(
* search.backward(/\w/, new vscode.Position(0, 0)),
* undefined,
* );
* ```
*
* With:
* ```
* abc
* ```
*/
export function backward(re: RegExp, origin: vscode.Position, end?: vscode.Position): Result {
end ??= Positions.zero;
const document = Context.current.document,
searchStart = document.offsetAt(end),
searchEnd = document.offsetAt(origin),
possibleSearchLength = searchEnd - searchStart;
if (possibleSearchLength < 0) {
return;
}
if (possibleSearchLength > 2_000) {
const staticMatches = regexp.matchesStaticStrings(re);
if (staticMatches !== undefined) {
return searchOneOfBackward(re, staticMatches, origin, end, document);
}
if (!regexp.canMatchLineFeed(re)) {
return searchSingleLineRegExpBackward(re, origin, end, document);
}
}
return searchNaiveBackward(re, origin, end, document);
}
/**
* Searches forward for a pattern starting at the given position.
*
* ### Example
*
* ```ts
* const [p1, [t1]] = search.forward(/\w/, new vscode.Position(0, 0))!;
*
* assert.deepStrictEqual(p1, new vscode.Position(0, 0));
* assert.strictEqual(t1, "a");
*
* const [p2, [t2]] = search.forward(/\w/, new vscode.Position(0, 1))!;
*
* assert.deepStrictEqual(p2, new vscode.Position(0, 1));
* assert.strictEqual(t2, "b");
*
* const [p3, [t3]] = search.forward(/\w+/, new vscode.Position(0, 1))!;
*
* assert.deepStrictEqual(p3, new vscode.Position(0, 1));
* assert.strictEqual(t3, "bc");
*
* assert.strictEqual(
* search.forward(/\w/, new vscode.Position(0, 3)),
* undefined,
* );
* ```
*
* With:
* ```
* abc
* ```
*/
export function forward(re: RegExp, origin: vscode.Position, end?: vscode.Position): Result {
const document = Context.current.document;
end ??= Positions.last(document);
const searchStart = document.offsetAt(origin),
searchEnd = document.offsetAt(end),
possibleSearchLength = searchEnd - searchStart;
if (possibleSearchLength < 0) {
return;
}
if (possibleSearchLength > 2_000) {
const staticMatches = regexp.matchesStaticStrings(re);
if (staticMatches !== undefined) {
return searchOneOfForward(re, staticMatches, origin, end, document);
}
if (!regexp.canMatchLineFeed(re)) {
return searchSingleLineRegExpForward(re, origin, end, document);
}
}
return searchNaiveForward(re, origin, end, document);
}
}
function maxLines(strings: readonly string[]) {
let max = 1;
for (const string of strings) {
let lines = 1;
for (let i = 0, len = string.length; i < len; i++) {
if (string.charCodeAt(i) === 10 /* \n */) {
lines++;
}
}
if (lines > max) {
max = lines;
}
}
return max;
}
function searchNaiveBackward(
re: RegExp,
origin: vscode.Position,
end: vscode.Position,
document: vscode.TextDocument,
) {
re.lastIndex = 0;
// Find all matches before the origin and take the last one.
const searchRange = new vscode.Range(end, origin),
match = regexp.execLast(re, document.getText(searchRange));
if (match === null) {
return;
}
return [Positions.offset(end, match.index), match] as search.Result;
}
function searchNaiveForward(
re: RegExp,
origin: vscode.Position,
end: vscode.Position,
document: vscode.TextDocument,
) {
re.lastIndex = 0;
// Look for a match in all the rest of the document.
const searchRange = new vscode.Range(origin, end),
match = re.exec(document.getText(searchRange));
if (match === null) {
return;
}
const matchPosition = document.positionAt(document.offsetAt(origin) + match.index);
return [matchPosition, match] as search.Result;
}
function searchSingleLineRegExpBackward(
re: RegExp,
origin: vscode.Position,
end: vscode.Position,
document: vscode.TextDocument,
) {
re.lastIndex = 0;
// Loop for a match line by line, starting at the current line.
const currentLine = document.lineAt(origin),
match = regexp.execLast(re, currentLine.text.slice(0, origin.character));
if (match !== null) {
return [new vscode.Position(origin.line, match.index), match] as search.Result;
}
const endLine = end.line;
for (let line = origin.line - 1; line > endLine; line--) {
const textLine = document.lineAt(line),
match = regexp.execLast(re, textLine.text);
if (match !== null) {
return [new vscode.Position(line, match.index), match] as search.Result;
}
}
const endMatch = regexp.execLast(re, document.lineAt(endLine).text.slice(end.character));
if (endMatch !== null) {
const endCharacter = end.character + endMatch.index;
return [new vscode.Position(endLine, endCharacter), endMatch] as search.Result;
}
return;
}
function searchSingleLineRegExpForward(
re: RegExp,
origin: vscode.Position,
end: vscode.Position,
document: vscode.TextDocument,
) {
re.lastIndex = 0;
// Loop for a match line by line, starting at the current line.
const currentLine = document.lineAt(origin),
match = re.exec(currentLine.text.slice(origin.character));
if (match !== null) {
return [origin.translate(undefined, match.index), match] as search.Result;
}
const endLine = end.line;
for (let line = origin.line + 1; line < endLine; line++) {
const textLine = document.lineAt(line),
match = re.exec(textLine.text);
if (match !== null) {
return [new vscode.Position(line, match.index), match] as search.Result;
}
}
const endMatch = re.exec(document.lineAt(endLine).text.slice(0, end.character));
if (endMatch !== null) {
return [new vscode.Position(endLine, endMatch.index), endMatch] as search.Result;
}
return;
}
function searchOneOfBackward(
re: RegExp,
oneOf: readonly string[],
origin: vscode.Position,
end: vscode.Position,
document: vscode.TextDocument,
) {
const lineRange = maxLines(oneOf);
if (lineRange === 1) {
return searchSingleLineRegExpBackward(re, origin, end, document);
}
const endLine = end.line;
if (origin.line - endLine < lineRange) {
return;
}
re.lastIndex = 0;
const originLine = origin.line,
lines = [document.lineAt(originLine).text.slice(0, origin.character)],
joiner = document.eol === vscode.EndOfLine.CRLF ? "\r\n" : "\n";
for (let i = 1; i < lineRange; i++) {
lines.unshift(document.lineAt(originLine - i).text);
}
const lineToSlice = end.character === 0 ? -1 : endLine;
for (let line = originLine - lineRange + 1; line >= endLine; line--) {
lines[0] = document.lineAt(line).text;
if (line === lineToSlice) {
lines[0] = lines[0].slice(end.character);
}
const text = lines.join(joiner),
match = re.exec(text);
if (match !== null) {
return [new vscode.Position(line, match.index), match] as search.Result;
}
for (let i = lineRange; i > 0; i--) {
lines[i] = lines[i + 1];
}
}
return;
}
function searchOneOfForward(
re: RegExp,
oneOf: readonly string[],
origin: vscode.Position,
end: vscode.Position,
document: vscode.TextDocument,
) {
const lineRange = maxLines(oneOf);
if (lineRange === 1) {
return searchSingleLineRegExpForward(re, origin, end, document);
}
const endLine = end.line;
if (origin.line + lineRange >= endLine) {
return;
}
re.lastIndex = 0;
const originLine = origin.line,
lines = [document.lineAt(originLine).text.slice(origin.character)],
joiner = document.eol === vscode.EndOfLine.CRLF ? "\r\n" : "\n";
for (let i = 1; i < lineRange; i++) {
lines.push(document.lineAt(originLine + i).text);
}
for (let line = originLine, loopEnd = endLine - lineRange + 1; line < loopEnd; line++) {
const text = lines.join(joiner),
match = re.exec(text);
if (match !== null) {
return [new vscode.Position(line, match.index), match] as search.Result;
}
for (let i = 1; i < lineRange; i++) {
lines[i - 1] = lines[i];
}
lines[lines.length - 1] = document.lineAt(line + lineRange).text;
if (line === loopEnd - 1) {
lines[lines.length - 1] = lines[lines.length - 1].slice(0, end.character);
}
}
return;
}

54
src/api/search/lines.ts Normal file
View File

@ -0,0 +1,54 @@
import * as vscode from "vscode";
import { Context } from "../context";
import { Positions } from "../positions";
import { lineByLine } from "./move";
/**
* Returns the range of lines matching the given `RegExp` before and after
* the given origin position.
*/
export function matchingLines(
re: RegExp,
origin: number | vscode.Position,
document = Context.current.document,
) {
const start = matchingLines.backward(re, origin, document),
end = matchingLines.forward(re, origin, document);
return new vscode.Range(start, end);
}
export namespace matchingLines {
/**
* Returns the position of the first line matching the given `RegExp`,
* starting at the `origin` line (included).
*/
export function backward(
re: RegExp,
origin: number | vscode.Position,
document = Context.current.document,
) {
return lineByLine.backward(
(text, position) => re.test(text) ? position : undefined,
typeof origin === "number" ? Positions.lineStart(origin) : origin,
document,
) ?? Positions.zero;
}
/**
* Returns the position of the last line matching the given `RegExp`, starting
* at the `origin` line (included).
*/
export function forward(
re: RegExp,
origin: number | vscode.Position,
document = Context.current.document,
) {
return lineByLine.forward(
(text, position) => re.test(text) ? position : undefined,
typeof origin === "number" ? Positions.lineStart(origin) : origin,
document,
) ?? Positions.lineStart(document.lineCount - 1);
}
}

100
src/api/search/move-to.ts Normal file
View File

@ -0,0 +1,100 @@
import * as vscode from "vscode";
import { Context, Direction } from "..";
import { Positions } from "../positions";
/**
* Moves the given position towards the given direction until the given string
* is found. If found, its start position will be returned; otherwise,
* `undefined` will be returned.
*
* When navigating backward, the returned position **will include** the given
* input, which is not the case when navigating forward. Please make sure that
* this is what you want.
*/
export function moveTo(
direction: Direction,
string: string,
origin: vscode.Position,
document = Context.current.document,
) {
let line = origin.line,
character: number | undefined = origin.character;
for (;;) {
const text = document.lineAt(line).text;
if (character === undefined) {
character = text.length;
}
const idx = direction === Direction.Backward
? text.lastIndexOf(string, character)
: text.indexOf(string, character);
if (idx !== -1) {
return new vscode.Position(line, idx);
}
// No match on this line, let's keep going.
if (direction === Direction.Backward) {
if (line === 0) {
return undefined;
}
line--;
character = undefined;
} else {
if (line === document.lineCount - 1) {
return undefined;
}
line++;
character = 0;
}
}
}
export namespace moveTo {
/**
* Same as `moveTo`, but also ensures that the result is excluded by
* translating the resulting position by `input.length` when going backward.
*
* @see moveTo
*/
export function excluded(
direction: Direction,
string: string,
origin: vscode.Position,
document?: vscode.TextDocument,
) {
const result = moveTo(direction, string, origin, document);
if (result !== undefined && direction === Direction.Backward) {
return Positions.offset(result, string.length, document);
}
return result;
}
/**
* Same as `moveTo`, but also ensures that the result is included by
* translating the resulting position by `input.length` when going forward.
*
* @see moveTo
*/
export function included(
direction: Direction,
string: string,
origin: vscode.Position,
document?: vscode.TextDocument,
) {
const result = moveTo(direction, string, origin, document);
if (result !== undefined && direction === Direction.Forward) {
return Positions.offset(result, string.length, document);
}
return result;
}
}

601
src/api/search/move.ts Normal file
View File

@ -0,0 +1,601 @@
import * as vscode from "vscode";
import { Context, Direction } from "..";
import { Positions } from "../positions";
/**
* Moves the given position towards the given direction as long as the given
* function returns a non-`undefined` value.
*
* @see moveWhile.backward,takeWhile.forward
*/
export function moveWith<T>(
direction: Direction,
reduce: moveWith.Reduce<T>,
startState: T,
origin: vscode.Position,
document?: vscode.TextDocument,
): vscode.Position {
return direction === Direction.Backward
? moveWith.backward(reduce, startState, origin, document)
: moveWith.forward(reduce, startState, origin, document);
}
export namespace moveWith {
/**
* A reduce function passed to `moveWith`.
*/
export interface Reduce<T> {
(character: string, state: T): T | undefined;
}
/**
* Whether the last call to `moveWith` (and variants) reached the edge of the
* document.
*/
export declare const reachedDocumentEdge: boolean;
/**
* Moves the given position backward as long as the state returned by the
* given function is not `undefined`.
*
* ### Example
*
* ```js
* assert.deepStrictEqual(
* moveWith.backward((c, i) => +c === +i - 1 ? c : undefined,
* "8", new vscode.Position(0, 8)),
* new vscode.Position(0, 7),
* );
* ```
*
* With:
* ```
* 1234578
* ```
*/
export function backward<T>(
reduce: Reduce<T>,
startState: T,
origin: vscode.Position,
document = Context.current.document,
) {
didReachDocumentEdge = false;
const currentLineText = document.lineAt(origin).text;
let state: T | undefined = startState;
for (let i = origin.character - 1; i >= 0; i--) {
if ((state = reduce(currentLineText[i], state)) === undefined) {
return new vscode.Position(origin.line, i + 1);
}
}
for (let line = origin.line - 1; line >= 0; line--) {
const lineText = document.lineAt(line).text;
if ((state = reduce("\n", state)) === undefined) {
return new vscode.Position(line + 1, 0);
}
for (let i = lineText.length - 1; i >= 0; i--) {
if ((state = reduce(lineText[i], state)) === undefined) {
return new vscode.Position(line, i + 1);
}
}
}
didReachDocumentEdge = true;
return new vscode.Position(0, 0);
}
/**
* Moves the given position forward as long as the state returned by the given
* function is not `undefined`.
*
* ### Example
*
* ```js
* assert.deepStrictEqual(
* moveWith.forward((c, i) => +c === +i + 1 ? c : undefined,
* "1", new vscode.Position(0, 8)),
* new vscode.Position(0, 7),
* );
* ```
*
* With:
* ```
* 1234578
* ```
*/
export function forward<T>(
reduce: Reduce<T>,
startState: T,
origin: vscode.Position,
document = Context.current.document,
) {
didReachDocumentEdge = false;
const currentLineText = document.lineAt(origin).text;
let state: T | undefined = startState;
for (let i = origin.character; i < currentLineText.length; i++) {
if ((state = reduce(currentLineText[i], state)) === undefined) {
return new vscode.Position(origin.line, i);
}
}
if ((state = reduce("\n", state)) === undefined) {
return new vscode.Position(origin.line, currentLineText.length);
}
for (let line = origin.line + 1; line < document.lineCount; line++) {
const lineText = document.lineAt(line).text;
for (let i = 0; i < lineText.length; i++) {
if ((state = reduce(lineText[i], state)) === undefined) {
return new vscode.Position(line, i);
}
}
if ((state = reduce("\n", state)) === undefined) {
return new vscode.Position(line, lineText.length);
}
}
didReachDocumentEdge = true;
return document.lineAt(document.lineCount - 1).range.end;
}
/**
* Same as `moveWith`, but using raw char codes.
*
* @see moveWith,byCharCode.backward,byCharCode.forward
*/
export function byCharCode<T>(
direction: Direction,
reduce: byCharCode.Reduce<T>,
startState: T,
origin: vscode.Position,
document?: vscode.TextDocument,
): vscode.Position {
return direction === Direction.Backward
? byCharCode.backward(reduce, startState, origin, document)
: byCharCode.forward(reduce, startState, origin, document);
}
export namespace byCharCode {
/**
* A reduce function passed to `moveWith.byCharCode`.
*/
export interface Reduce<T> {
(charCode: number, state: T): T | undefined;
}
/**
* Same as `moveWith.backward`, but using raw char codes.
*
* @see moveWith.backward
*/
export function backward<T>(
reduce: Reduce<T>,
startState: T,
origin: vscode.Position,
document = Context.current.document,
) {
didReachDocumentEdge = false;
const currentLineText = document.lineAt(origin).text;
let state: T | undefined = startState;
for (let i = origin.character - 1; i >= 0; i--) {
if ((state = reduce(currentLineText.charCodeAt(i), state)) === undefined) {
return new vscode.Position(origin.line, i + 1);
}
}
for (let line = origin.line - 1; line >= 0; line--) {
const lineText = document.lineAt(line).text;
if ((state = reduce(10 /* \n */, state)) === undefined) {
return new vscode.Position(line + 1, 0);
}
for (let i = lineText.length - 1; i >= 0; i--) {
if ((state = reduce(lineText.charCodeAt(i), state)) === undefined) {
return new vscode.Position(line, i + 1);
}
}
}
didReachDocumentEdge = true;
return new vscode.Position(0, 0);
}
/**
* Same as `moveWith.forward`, but using raw char codes.
*
* @see moveWith.forward
*/
export function forward<T>(
reduce: Reduce<T>,
startState: T,
origin: vscode.Position,
document = Context.current.document,
) {
didReachDocumentEdge = false;
const currentLineText = document.lineAt(origin).text;
let state: T | undefined = startState;
for (let i = origin.character; i < currentLineText.length; i++) {
if ((state = reduce(currentLineText.charCodeAt(i), state)) === undefined) {
return new vscode.Position(origin.line, i);
}
}
if ((state = reduce(10 /* \n */, state)) === undefined) {
return new vscode.Position(origin.line, currentLineText.length);
}
for (let line = origin.line + 1; line < document.lineCount; line++) {
const lineText = document.lineAt(line).text;
for (let i = 0; i < lineText.length; i++) {
if ((state = reduce(lineText.charCodeAt(i), state)) === undefined) {
return new vscode.Position(line, i);
}
}
if ((state = reduce(10 /* \n */, state)) === undefined) {
return new vscode.Position(line, lineText.length);
}
}
didReachDocumentEdge = true;
return document.lineAt(document.lineCount - 1).range.end;
}
}
}
/**
* Moves the given position towards the given direction as long as the given
* predicate is true.
*
* @see moveWhile.backward,takeWhile.forward
*/
export function moveWhile(
direction: Direction,
predicate: moveWhile.Predicate,
origin: vscode.Position,
document?: vscode.TextDocument,
): vscode.Position {
return direction === Direction.Backward
? moveWhile.backward(predicate, origin, document)
: moveWhile.forward(predicate, origin, document);
}
export namespace moveWhile {
/**
* A predicate passed to `moveWhile`.
*/
export interface Predicate {
(character: string): boolean;
}
/**
* Whether the last call to `moveWhile` (and variants) reached the edge of the
* document.
*/
export declare const reachedDocumentEdge: boolean;
/**
* Moves the given position backward as long as the given predicate is true.
*
* ### Example
*
* ```js
* assert.deepStrictEqual(
* moveWhile.backward((c) => /\w/.test(c), new vscode.Position(0, 3)),
* new vscode.Position(0, 0),
* );
*
* assert.deepStrictEqual(
* moveWhile.backward((c) => c === "c", new vscode.Position(0, 3)),
* new vscode.Position(0, 2),
* );
*
* assert.deepStrictEqual(
* moveWhile.backward((c) => c === "b", new vscode.Position(0, 3)),
* new vscode.Position(0, 3),
* );
* ```
*
* With:
* ```
* abc
* ```
*/
export function backward(
predicate: Predicate,
origin: vscode.Position,
document?: vscode.TextDocument,
): vscode.Position {
return moveWith.backward((ch) => predicate(ch) ? null : undefined, null, origin, document);
}
/**
* Moves the given position forward as long as the given predicate is true.
*
* ### Example
*
* ```js
* assert.deepStrictEqual(
* moveWhile.forward((c) => /\w/.test(c), new vscode.Position(0, 0)),
* new vscode.Position(0, 3),
* );
*
* assert.deepStrictEqual(
* moveWhile.forward((c) => c === "a", new vscode.Position(0, 0)),
* new vscode.Position(0, 1),
* );
*
* assert.deepStrictEqual(
* moveWhile.forward((c) => c === "b", new vscode.Position(0, 0)),
* new vscode.Position(0, 0),
* );
* ```
*
* With:
* ```
* abc
* ```
*/
export function forward(
predicate: Predicate,
origin: vscode.Position,
document?: vscode.TextDocument,
): vscode.Position {
return moveWith.forward((ch) => predicate(ch) ? null : undefined, null, origin, document);
}
/**
* Same as `moveWith`, but using raw char codes.
*
* @see moveWith,byCharCode.backward,byCharCode.forward
*/
export function byCharCode(
direction: Direction,
predicate: byCharCode.Predicate,
origin: vscode.Position,
document?: vscode.TextDocument,
): vscode.Position {
return direction === Direction.Backward
? byCharCode.backward(predicate, origin, document)
: byCharCode.forward(predicate, origin, document);
}
export namespace byCharCode {
/**
* A predicate passed to `moveWhile.byCharCode`.
*/
export interface Predicate {
(charCode: number): boolean;
}
/**
* Same as `moveWhile.backward`, but using raw char codes.
*
* @see moveWhile.backward
*/
export function backward(
predicate: Predicate,
origin: vscode.Position,
document?: vscode.TextDocument,
): vscode.Position {
return moveWith.byCharCode.backward(
(ch) => predicate(ch) ? null : undefined,
null,
origin,
document,
);
}
/**
* Same as `moveWhile.forward`, but using raw char codes.
*
* @see moveWhile.forward
*/
export function forward(
predicate: Predicate,
origin: vscode.Position,
document?: vscode.TextDocument,
): vscode.Position {
return moveWith.byCharCode.forward(
(ch) => predicate(ch) ? null : undefined,
null,
origin,
document,
);
}
}
}
/**
* Moves the given position line-by-line towards the given direction as long as
* the given function returns `undefined`, and returns the first non-`undefined`
* value it returns or `undefined` if the edge of the document is reached.
*
* @see lineByLine.backward,lineByLine.forward
*/
export function lineByLine<T>(
direction: Direction,
seek: lineByLine.Seek<T>,
origin: vscode.Position,
document?: vscode.TextDocument,
) {
return direction === Direction.Backward
? lineByLine.backward(seek, origin, document)
: lineByLine.forward(seek, origin, document);
}
export namespace lineByLine {
/**
* A reduce function passed to `lineByLine`.
*/
export interface Seek<T> {
(lineText: string, lineStart: vscode.Position): T | undefined;
}
/**
* Whether the last call to `lineByLine` (and variants) reached the edge of
* the document.
*/
export declare const reachedDocumentEdge: boolean;
/**
* Moves the given position backward line-by-line as long as the given
* function returns `undefined`, and returns the first non-`undefined`
* value it returns or `undefined` if the start of the document is reached.
*/
export function backward<T>(
seek: Seek<T>,
origin: vscode.Position,
document = Context.current.document,
) {
didReachDocumentEdge = false;
const originLine = document.lineAt(origin),
originLineText = originLine.text.slice(0, origin.character),
originResult = seek(originLineText, Positions.lineStart(origin.line));
if (originResult !== undefined) {
return originResult;
}
for (let line = origin.line - 1; line >= 0; line--) {
const lineText = document.lineAt(line).text,
result = seek(lineText, Positions.lineStart(line));
if (result !== undefined) {
return result;
}
}
didReachDocumentEdge = true;
return undefined;
}
/**
* Moves the given position forward line-by-line as long as the given
* function returns `undefined`, and returns the first non-`undefined`
* value it returns or `undefined` if the end of the document is reached.
*/
export function forward<T>(
seek: Seek<T>,
origin: vscode.Position,
document = Context.current.document,
) {
didReachDocumentEdge = false;
const originLine = document.lineAt(origin),
originLineText = originLine.text.slice(origin.character),
originResult = seek(originLineText, origin);
if (originResult !== undefined) {
return originResult;
}
for (let line = origin.line + 1, lineCount = document.lineCount; line < lineCount; line++) {
const lineText = document.lineAt(line).text,
result = seek(lineText, Positions.lineStart(line));
if (result !== undefined) {
return result;
}
}
didReachDocumentEdge = true;
return undefined;
}
}
/**
* Advances in the given direction as long as lines are empty, and returns the
* position closest to the origin in a non-empty line. The origin line will
* always be skipped.
*/
export function skipEmptyLines(
direction: Direction,
origin: number | vscode.Position,
document = Context.current.document,
) {
didReachDocumentEdge = false;
let line = typeof origin === "number" ? origin : origin.line;
while (line >= 0 && line < document.lineCount) {
const lineLength = document.lineAt(line).text.length;
if (lineLength > 0) {
return new vscode.Position(
line,
direction === Direction.Backward ? lineLength : 0,
);
}
line += direction;
}
didReachDocumentEdge = true;
return Positions.edge(direction, document);
}
export namespace skipEmptyLines {
/**
* Whether the last call to `skipEmptyLines` (and variants) reached the edge
* of the document.
*/
export declare const reachedDocumentEdge: boolean;
/**
* Same as `skipEmptyLines` with a `Backward` direction.
*
* @see skipEmptyLines
*/
export function backward(origin: number | vscode.Position, document?: vscode.TextDocument) {
return skipEmptyLines(Direction.Backward, origin, document);
}
/**
* Same as `skipEmptyLines` with a `Forward` direction.
*
* @see skipEmptyLines
*/
export function forward(origin: number | vscode.Position, document?: vscode.TextDocument) {
return skipEmptyLines(Direction.Forward, origin, document);
}
}
let didReachDocumentEdge = false;
const getReachedDocumentEdge = {
get() {
return didReachDocumentEdge;
},
};
for (const obj of [
lineByLine,
moveWhile,
moveWhile.byCharCode,
moveWith,
moveWith.byCharCode,
skipEmptyLines,
]) {
Object.defineProperty(obj, "reachedDocumentEdge", getReachedDocumentEdge);
}

177
src/api/search/pairs.ts Normal file
View File

@ -0,0 +1,177 @@
import * as vscode from "vscode";
import { search } from ".";
import { Direction } from "..";
import { anyRegExp, escapeForRegExp } from "../../utils/regexp";
import { Context } from "../context";
import { ArgumentError } from "../errors";
import { Positions } from "../positions";
/**
* A pair of opening and closing patterns.
*/
export class Pair {
private readonly _re: RegExp;
private readonly _closeGroup: number;
public constructor(
public readonly open: RegExp,
public readonly close: RegExp,
) {
const [re, group] = anyRegExp(open, close);
this._re = re;
this._closeGroup = group;
}
public searchMatching(
direction: Direction,
searchOrigin: vscode.Position,
balance = 1,
): search.Result {
ArgumentError.validate("balance", balance !== 0, "balance cannot be null");
const re = this._re,
closeGroup = this._closeGroup;
for (;;) {
const result = search(direction, re, searchOrigin);
if (result === undefined) {
return undefined;
}
const match = result[1];
if (match[closeGroup] === undefined) {
// Opening pattern matched.
balance++;
} else {
// Closing pattern matched.
balance--;
}
if (balance === 0) {
return result;
}
if (direction === Direction.Forward) {
searchOrigin = Positions.offset(result[0], match[0].length)!;
} else {
searchOrigin = result[0];
}
}
}
public findMatching(
direction: Direction,
searchOrigin: vscode.Position,
balance = 1,
included = true,
) {
const result = this.searchMatching(direction, searchOrigin, balance);
if (result === undefined) {
return undefined;
}
if (direction === Direction.Backward) {
return included ? result[0] : Positions.offset(result[0], result[1][0].length)!;
}
return included ? Positions.offset(result[0], result[1][0].length)! : result[0];
}
public searchOpening(
searchOrigin: vscode.Position,
balance = -1,
): search.Result {
return this.searchMatching(Direction.Backward, searchOrigin, balance);
}
public searchClosing(
searchOrigin: vscode.Position,
balance = this.open.source === this.close.source ? -1 : 1,
): search.Result {
return this.searchMatching(Direction.Forward, searchOrigin, balance);
}
}
/**
* Returns a new `Pair`.
*/
export function pair(open: string | RegExp, close: string | RegExp): Pair {
if (typeof open === "string") {
open = new RegExp(escapeForRegExp(open), "um");
}
if (typeof close === "string") {
close = new RegExp(escapeForRegExp(close), "um");
}
return new Pair(open, close);
}
/**
* Returns the next selection enclosed in one of the given pairs. The resulting
* selection is anchored on the first closing or opening pattern encountered in
* the given direction, and its cursor will be on its matching pattern.
*/
export function surroundedBy(
pairs: readonly Pair[],
direction: Direction,
searchOrigin: vscode.Position,
open = true,
document = Context.current.document,
) {
const re = new RegExp(pairs.map((p) => `(${p.open.source})|(${p.close.source})`).join("|"), "u"),
anchorSearch = search(direction, re, searchOrigin);
if (anchorSearch === undefined) {
return undefined;
}
const match = anchorSearch[1],
index = match.findIndex((x, i) => i > 0 && x !== undefined) - 1,
pairIndex = (index & 0xffff_fffe) >> 1,
pair = pairs[pairIndex];
// Then, find the matching char of the anchor.
let anchor = anchorSearch[0],
active: vscode.Position;
if (index & 1) {
// Index is odd
// <=> match is for closing pattern
// <=> we go backward looking for the opening pattern
const activeSearch = pair.searchOpening(anchor);
if (activeSearch === undefined) {
return undefined;
}
if (open) {
anchor = Positions.offset(anchor, match[0].length, document)!;
active = activeSearch[0];
} else {
active = Positions.offset(activeSearch[0], activeSearch[1][0].length, document)!;
}
} else {
// Index is even
// <=> match is for opening pattern
// <=> we go forward looking for the closing pattern
const searchAnchor = Positions.offset(anchor, match[0].length, document)!,
activeSearch = pair.searchClosing(searchAnchor);
if (activeSearch === undefined) {
return undefined;
}
if (open) {
active = Positions.offset(activeSearch[0], activeSearch[1][0].length, document)!;
} else {
anchor = searchAnchor;
active = activeSearch[0];
}
}
return new vscode.Selection(anchor, active);
}

634
src/api/search/range.ts Normal file
View File

@ -0,0 +1,634 @@
import * as vscode from "vscode";
import { CharCodes } from "../../utils/regexp";
import { Direction } from "..";
import { Context } from "../context";
import { Positions } from "../positions";
import { moveWhile } from "./move";
import { CharSet, getCharSetFunction } from "../../utils/charset";
import { Lines } from "../lines";
import { Selections } from "../selections";
export namespace Range {
/**
* A function that, given a position, returns the start of the object to which
* the position belongs.
*/
export interface SeekStart {
(position: vscode.Position, inner: boolean, document: vscode.TextDocument): vscode.Position;
}
/**
* A function that, given a position, returns the end of the object to which
* the position belongs.
*
* If the whole object is being saught, the start position of the object will
* also be given.
*/
export interface SeekEnd {
(position: vscode.Position, inner: boolean,
document: vscode.TextDocument, start?: vscode.Position): vscode.Position;
}
/**
* A function that, given a position, returns the range of teh object to which
* the position belongs.
*/
export interface Seek {
(position: vscode.Position, inner: boolean, document: vscode.TextDocument): vscode.Selection;
readonly start: SeekStart;
readonly end: SeekEnd;
}
/**
* Returns the range of the argument at the given position.
*/
export function argument(
position: vscode.Position,
inner: boolean,
document = Context.current.document,
) {
return new vscode.Selection(
argument.start(position, inner, document),
argument.end(position, inner, document),
);
}
export namespace argument {
/**
* Returns the start position of the argument at the given position.
*/
export function start(
position: vscode.Position,
inner: boolean,
document = Context.current.document,
) {
return toArgumentEdge(position, inner, Direction.Backward, document);
}
/**
* Returns the end position of the argument at the given position.
*/
export function end(
position: vscode.Position,
inner: boolean,
document = Context.current.document,
) {
return toArgumentEdge(position, inner, Direction.Forward, document);
}
}
/**
* Returns the range of lines with the same indent as the line at the given
* position.
*/
export function indent(
position: vscode.Position,
inner: boolean,
document = Context.current.document,
) {
// When selecting a whole indent object, scanning separately to start and
// then to end will lead to wrong results like two different indentation
// levels and skipping over blank lines more than needed. We can mitigate
// this by finding the start first and then scan from there to find the end
// of indent block.
const start = indent.start(position, inner, document),
end = indent.end(start, inner, document);
return new vscode.Selection(start, end);
}
export namespace indent {
/**
* Returns the start position of the indent block at the given position.
*/
export function start(
position: vscode.Position,
inner: boolean,
document = Context.current.document,
) {
return toIndentEdge(position, inner, Direction.Backward, document);
}
/**
* Returns the end position of the indent block at the given position.
*/
export function end(
position: vscode.Position,
inner: boolean,
document = Context.current.document,
start?: vscode.Position,
) {
return toIndentEdge(start ?? position, inner, Direction.Forward, document);
}
}
/**
* Returns the range of the paragraph that wraps the given position.
*/
export function paragraph(
position: vscode.Position,
inner: boolean,
document = Context.current.document,
) {
let start: vscode.Position;
if (position.line + 1 < document.lineCount
&& Lines.isEmpty(position.line, document) && !Lines.isEmpty(position.line + 1, document)) {
// Special case: if current line is empty, check next line and select
// the NEXT paragraph if next line is not empty.
start = Positions.lineStart(position.line + 1);
} else {
start = toParagraphStart(position, document);
}
const end = toParagraphEnd(start, inner, document);
return new vscode.Selection(start, end);
}
export namespace paragraph {
/**
* Returns the start position of the paragraph that wraps the given
* position.
*/
export function start(
position: vscode.Position,
_inner: boolean,
document = Context.current.document,
) {
if (position.line > 0 && Lines.isEmpty(position.line, document)) {
position = Positions.lineStart(position.line - 1); // Re-anchor to the previous line.
}
return toParagraphStart(position, document);
}
/**
* Returns the end position of the paragraph that wraps given position.
*/
export function end(
position: vscode.Position,
inner: boolean,
document = Context.current.document,
start?: vscode.Position,
) {
if (start !== undefined) {
// It's much easier to check from start.
position = start;
}
return toParagraphEnd(position, inner, document);
}
}
/**
* Returns the range of the sentence that wraps the given position.
*/
export function sentence(
position: vscode.Position,
inner: boolean,
document = Context.current.document,
) {
const beforeBlank = toBeforeBlank(position, document, /* canSkipToPrevious= */ false),
start = toSentenceStart(beforeBlank, document),
end = sentence.end(start, inner, document);
return new vscode.Selection(start, end);
}
export namespace sentence {
/**
* Returns the start position of the sentence that wraps the given position.
*/
export function start(
position: vscode.Position,
_inner: boolean,
document = Context.current.document,
) {
// Special case to allow jumping to the previous sentence when position is
// at current sentence start / leading blank chars.
const beforeBlank = toBeforeBlank(position, document, /* canSkipToPrevious= */ true);
return toSentenceStart(beforeBlank, document);
}
/**
* Returns the end position of the sentence that wraps given position.
*/
export function end(
position: vscode.Position,
inner: boolean,
document = Context.current.document,
start?: vscode.Position,
) {
if (start !== undefined) {
// It is imposssible to determine if active is at leading or trailing or
// in-sentence blank characters by just looking ahead. Therefore, we
// search from the sentence start, which may be slightly less efficient
// but always accurate.
position = start;
}
if (Lines.isEmpty(position.line, document)) {
// We're on an empty line which does not belong to last sentence or this
// sentence. If next line is also empty, we should just stay here.
// However, start scanning from the next line if it is not empty.
if (position.line + 1 >= document.lineCount || Lines.isEmpty(position.line + 1, document)) {
return position;
} else {
position = Positions.lineStart(position.line + 1);
}
}
const isBlank = getCharSetFunction(CharSet.Blank, document);
let hadLf = false;
const innerEnd = moveWhile.byCharCode.forward(
(charCode) => {
if (charCode === CharCodes.LF) {
if (hadLf) {
return false;
}
hadLf = true;
} else {
hadLf = false;
if (punctCharCodes.indexOf(charCode) >= 0) {
return false;
}
}
return true;
},
position,
document,
);
if (moveWhile.reachedDocumentEdge) {
return innerEnd;
}
// If a sentence ends with two LFs in a row, then the first LF is part of
// the inner & outer sentence while the second LF should be excluded.
if (hadLf) {
if (inner) {
return Positions.previous(innerEnd, document)!;
}
return innerEnd;
}
if (inner) {
return innerEnd;
}
// If a sentence ends with punct char, then any blank characters after it
// but BEFORE any line breaks belongs to the outer sentence.
let col = innerEnd.character + 1;
const text = document.lineAt(innerEnd.line).text;
while (col < text.length && isBlank(text.charCodeAt(col))) {
col++;
}
if (col >= text.length) {
return Positions.lineBreak(innerEnd.line, document);
}
return new vscode.Position(innerEnd.line, col);
}
}
}
const punctCharCodes = new Uint32Array(Array.from(".!?¡§¶¿;՞。", (ch) => ch.charCodeAt(0)));
// ^
// I bet that's the first time you see a Greek question mark used as an actual
// Greek question mark, rather than as a "prank" semicolon.
function toArgumentEdge(
from: vscode.Position,
inner: boolean,
direction: Direction,
document: vscode.TextDocument,
) {
const paren = direction === Direction.Backward ? CharCodes.LParen : CharCodes.RParen;
let bbalance = 0,
pbalance = 0;
const afterSkip = moveWhile.byCharCode(
direction,
(charCode) => {
if (charCode === paren && pbalance === 0 && bbalance === 0) {
return false;
} else if (charCode === CharCodes.LParen) {
pbalance++;
} else if (charCode === CharCodes.LBracket) {
bbalance++;
} else if (charCode === CharCodes.RParen) {
pbalance--;
} else if (charCode === CharCodes.RBracket) {
bbalance--;
} else if (pbalance !== 0 || bbalance !== 0) {
// Nop.
} else if (charCode === CharCodes.Comma) {
return false;
}
return true;
},
from,
document,
);
let end: vscode.Position;
if (moveWhile.reachedDocumentEdge) {
end = afterSkip;
} else {
const charCode = document.lineAt(afterSkip.line).text.charCodeAt(afterSkip.character);
// Make sure parens are not included in the object. Deliminator commas
// after the argument is included as outer, but ones before are NOT.
// TODO: Kakoune seems to have more sophisticated edge cases for commas,
// e.g. outer last argument includes the comma before it, plus more edge
// cases for who owns the whitespace. Those are not implemented for now
// because they require extensive tests and mess a lot with the logic of
// selecting the whole object.
if (inner || charCode === paren || direction === Direction.Backward) {
end = Positions.offset(afterSkip, -direction, document)!;
} else {
end = afterSkip;
}
}
if (!inner) {
return end;
}
const isBlank = getCharSetFunction(CharSet.Blank, document);
// Exclude any surrounding whitespaces.
return moveWhile.byCharCode(-direction, isBlank, end, document);
}
function toIndentEdge(
from: vscode.Position,
inner: boolean,
direction: Direction,
document: vscode.TextDocument,
) {
let line = from.line,
textLine = document.lineAt(line);
// First, scan backwards through blank lines. (Note that whitespace-only
// lines do not count -- those have a proper indentation level and should
// be treated as the inner part of the indent block.)
while (textLine.text.length === 0) {
line += direction;
if (line < 0) {
return Positions.zero;
}
if (line >= document.lineCount) {
return Positions.last(document);
}
textLine = document.lineAt(line);
}
const indent = textLine.firstNonWhitespaceCharacterIndex;
let lastNonBlankLine = line;
for (;;) {
line += direction;
if (line < 0) {
return Positions.zero;
}
if (line >= document.lineCount) {
return Positions.last(document);
}
textLine = document.lineAt(line);
if (textLine.text.length === 0) {
continue;
}
if (textLine.firstNonWhitespaceCharacterIndex < indent) {
const resultLine = inner ? lastNonBlankLine : line - direction;
return direction === Direction.Backward
? Positions.lineStart(resultLine)
: Positions.lineBreak(resultLine, document);
}
lastNonBlankLine = line;
}
}
function toParagraphStart(
position: vscode.Position,
document: vscode.TextDocument,
) {
let line = position.line;
// Move past any trailing empty lines.
while (line >= 0 && Lines.isEmpty(line, document)) {
line--;
}
if (line <= 0) {
return Positions.zero;
}
// Then move to the start of the paragraph (non-empty lines).
while (line > 0 && !Lines.isEmpty(line - 1, document)) {
line--;
}
return Positions.lineStart(line);
}
function toParagraphEnd(
position: vscode.Position,
inner: boolean,
document: vscode.TextDocument,
) {
let line = position.line;
// Move to the end of the paragraph (non-empty lines)
while (line < document.lineCount && !Lines.isEmpty(line, document)) {
line++;
}
if (line >= document.lineCount) {
return Positions.last(document);
}
if (inner) {
if (line > 0) {
line--;
}
return Positions.lineBreak(line, document);
}
// Then move to the last trailing empty line.
while (line + 1 < document.lineCount && Lines.isEmpty(line + 1, document)) {
line++;
}
return Positions.lineBreak(line, document);
}
function toBeforeBlank(
position: vscode.Position,
document: vscode.TextDocument,
canSkipToPrevious: boolean,
) {
const isBlank = getCharSetFunction(CharSet.Blank, document);
let jumpedOverBlankLine = false,
hadLf = true;
const beforeBlank = moveWhile.byCharCode.backward(
(charCode) => {
if (charCode === CharCodes.LF) {
if (hadLf) {
jumpedOverBlankLine = true;
return canSkipToPrevious;
}
hadLf = true;
return true;
} else {
hadLf = false;
return isBlank(charCode);
}
},
position,
document,
);
if (moveWhile.reachedDocumentEdge) {
return position;
}
const beforeBlankChar = document.lineAt(beforeBlank.line).text.charCodeAt(beforeBlank.character),
hitPunctChar = punctCharCodes.includes(beforeBlankChar);
if (jumpedOverBlankLine && (!canSkipToPrevious || !hitPunctChar)) {
// We jumped over blank lines but didn't hit a punct char. Don't accept.
return position;
}
if (!hitPunctChar || canSkipToPrevious || position.line === beforeBlank.line) {
return beforeBlank;
}
// Example below: we started from '|' and found the '.'.
// foo.
// | bar
// In this case, technically we started from the second sentence
// and reached the first sentence. This is not permitted when when
// allowSkipToPrevious is false, so let's go back.
return position;
}
function toSentenceStart(
position: vscode.Position,
document: vscode.TextDocument,
) {
const isBlank = getCharSetFunction(CharSet.Blank, document);
let originLineText = document.lineAt(position.line).text;
if (originLineText.length === 0 && position.line + 1 >= document.lineCount) {
if (position.line === 0) {
// There is only one line and that line is empty. What a life.
return Positions.zero;
}
// Special case: If at the last line, search from the previous line.
originLineText = document.lineAt(position.line - 1).text;
position = new vscode.Position(position.line - 1, originLineText.length);
}
if (originLineText.length === 0) {
// This line is empty. Just go to the first non-blank char on next line.
const nextLineText = document.lineAt(position.line + 1).text;
let col = 0;
while (col < nextLineText.length && isBlank(nextLineText.charCodeAt(col))) {
col++;
}
return new vscode.Position(position.line + 1, col);
}
let first = true,
hadLf = false;
const afterSkip = moveWhile.byCharCode.backward(
(charCode) => {
if (charCode === CharCodes.LF) {
first = false;
if (hadLf) {
return false;
}
hadLf = true;
} else {
hadLf = false;
if (first) {
// Don't need to check if first character encountered is punct --
// that may be the current sentence end.
first = false;
return true;
}
if (punctCharCodes.indexOf(charCode) >= 0) {
return false;
}
}
return true;
},
position,
document,
);
// If we hit two LFs or document start, the current sentence starts at the
// first non-blank character after that.
if (hadLf || moveWhile.reachedDocumentEdge) {
const start = moveWhile.byCharCode.forward(isBlank, afterSkip, document);
if (moveWhile.reachedDocumentEdge) {
return Positions.zero;
}
return start;
}
// If we hit a punct char, then the current sentence starts on the first
// non-blank character on the same line, or the line break.
let col = afterSkip.character;
const text = document.lineAt(afterSkip.line).text;
while (col < text.length && isBlank(text.charCodeAt(col))) {
col++;
}
return new vscode.Position(afterSkip.line, col);
}

130
src/api/search/word.ts Normal file
View File

@ -0,0 +1,130 @@
import * as vscode from "vscode";
import { Direction, skipEmptyLines } from "..";
import { SelectionBehavior } from "../../state/modes";
import { CharSet, getCharSetFunction } from "../../utils/charset";
import { Context } from "../context";
const enum WordCategory {
Word,
Blank,
Punctuation,
}
function categorize(
charCode: number,
isBlank: (charCode: number) => boolean,
isWord: (charCode: number) => boolean,
) {
return isWord(charCode)
? WordCategory.Word
: charCode === 0 || isBlank(charCode) ? WordCategory.Blank : WordCategory.Punctuation;
}
/**
* Starting at the given `origin` position, seeks the next (or previous) word.
* Returns a selection wrapping the next word.
*/
export function wordBoundary(
direction: Direction,
origin: vscode.Position,
stopAtEnd: boolean,
wordCharset: CharSet,
context = Context.current,
) {
let anchor = undefined,
active = origin;
const document = context.document,
text = document.lineAt(active.line).text,
lineEndCol = context.selectionBehavior === SelectionBehavior.Caret
? text.length
: text.length - 1;
const isWord = getCharSetFunction(wordCharset, document),
isBlank = getCharSetFunction(CharSet.Blank, document),
isPunctuation = getCharSetFunction(CharSet.Punctuation, document);
// Starting from active, try to seek to the word start.
const isAtLineBoundary = direction === Direction.Forward
? (active.character >= lineEndCol)
: (active.character === 0 || active.character === 1);
if (isAtLineBoundary) {
const afterEmptyLines = skipEmptyLines(direction, active.line + direction, document);
if (skipEmptyLines.reachedDocumentEdge) {
return undefined;
}
anchor = afterEmptyLines;
} else {
let shouldSkip: boolean;
if (context.selectionBehavior === SelectionBehavior.Character) {
// Skip current character if it is at boundary.
// (e.g. "ab[c] " =>`w`)
const col = active.character - +(direction === Direction.Backward),
characterCategory = categorize(text.charCodeAt(col), isBlank, isWord),
nextCharacterCategory = categorize(text.charCodeAt(col + direction), isBlank, isWord);
shouldSkip = characterCategory !== nextCharacterCategory;
if (shouldSkip && stopAtEnd === (direction === Direction.Forward)
&& (characterCategory === WordCategory.Blank)) {
shouldSkip = false;
}
} else {
shouldSkip = false;
}
anchor = shouldSkip ? new vscode.Position(active.line, active.character + direction) : active;
}
active = anchor;
// Scan within the current line until the word ends.
const curLineText = document.lineAt(active).text;
let nextCol = active.character; // The next character to be tested.
if (direction === Direction.Backward) {
nextCol--;
}
if (stopAtEnd === (direction === Direction.Forward)) {
// Select the whitespace before word, if any.
while (nextCol >= 0 && nextCol < curLineText.length
&& isBlank(curLineText.charCodeAt(nextCol))) {
nextCol += direction;
}
}
if (nextCol >= 0 && nextCol < curLineText.length) {
const startCharCode = curLineText.charCodeAt(nextCol),
isSameCategory = isWord(startCharCode) ? isWord : isPunctuation;
while (nextCol >= 0 && nextCol < curLineText.length
&& isSameCategory(curLineText.charCodeAt(nextCol))) {
nextCol += direction;
}
}
if (stopAtEnd === (direction === Direction.Backward)) {
// Select the whitespace after word, if any.
while (nextCol >= 0 && nextCol < curLineText.length
&& isBlank(curLineText.charCodeAt(nextCol))) {
nextCol += direction;
}
}
if (direction === Direction.Backward) {
// If we reach here, nextCol must be the first character we encounter
// that does not belong to the current word (or -1 / line break).
// Exclude it.
active = new vscode.Position(active.line, nextCol + 1);
} else {
active = new vscode.Position(active.line, nextCol);
}
return new vscode.Selection(anchor, active);
}

1892
src/api/selections.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,118 @@
import { Builder, parseKeys, unindent } from "../../meta";
export async function build(builder: Builder) {
const commandModules = await builder.getCommandModules();
return unindent(4, `
<details>
<summary><b>Quick reference</b></summary>
${toTable(commandModules)}
</details>
${commandModules.map((module) => unindent(8, `
## [\`${module.name}\`](./${module.name}.ts)
${module.doc!.trim()}
${module.functions.map((f) => unindent(12, `
### [\`${module.name === "misc" ? "" : module.name + "."}${f.nameWithDot}\`](./${
module.name}.ts#L${f.startLine + 1}-L${f.endLine + 1})
${f.doc}
${(() => {
const supportedInputs = determineSupportedInputs(f);
return supportedInputs.length === 0
? ""
: "This command:" + supportedInputs.map((x) => `\n- ${x}.`).join("");
})()}
`).trim()).join("\n\n")}
`).trim()).join("\n\n")}
`);
}
function toTable(modules: readonly Builder.ParsedModule[]) {
const rows: string[][] = modules.flatMap((module) => {
const modulePrefix = module.name === "misc" ? "" : module.name + ".",
allCommands = [] as (Builder.ParsedFunction | Builder.AdditionalCommand)[];
allCommands.push(...module.functions);
allCommands.push(
...module.additional
.concat(...module.functions.flatMap((f) => f.additional))
.filter((a) => a.qualifiedIdentifier && a.identifier),
);
allCommands.sort((a, b) => {
const aName = "name" in a ? a.name : a.qualifiedIdentifier!,
bName = "name" in b ? b.name : b.qualifiedIdentifier!;
return aName.localeCompare(bName);
});
return allCommands.map((f, i, { length }) => {
const identifier = "name" in f ? modulePrefix + f.nameWithDot : f.qualifiedIdentifier,
summary = "summary" in f ? f.summary : f.title,
keys = parseKeys(("properties" in f ? f.properties.keys : f.keys) ?? ""),
link = "name" in f
? `#${(modulePrefix + f.nameWithDot).replace(/\./g, "")}`
: `./${module.name}.ts#L${f.line + 1}`;
return [
i === 0
? `<td rowspan=${length}><a href="#${module.name}"><code>${module.name}</code></a></td>`
: "",
`<td><a href="${link}"><code>${identifier}</code></a></td>`,
`<td>${summary}</td>`,
`<td>${
keys.map(({ key, when }) => `<code>${key}</code> (<code>${when}</code>)`).join("")
}</td>`,
];
});
});
return `
<table>
<thead>
<tr>
${["Category", "Identifier", "Title", "Default keybindings"]
.map((h) => `<th>${h}</th>`).join("")}
</tr>
</thead>
<tbody>
${rows.map((row) => `<tr>${row.join("")}</tr>`).join("\n ")}
</tbody>
</table>
`;
}
function determineSupportedInputs(f: Builder.ParsedFunction) {
const supported: string[] = [];
let requiresActiveEditor = false;
for (const [name, type] of f.parameters) {
let match: RegExpExecArray | null;
if (name === "count" || name === "repetitions") {
supported.push("may be repeated with a given number of repetitions");
} else if (match = /^RegisterOr<"(\w+)"(?:, .+)?>$/.exec(type)) {
supported.push(`accepts a register (by default, it uses \`${match[1]}\`)`);
} else if (match = /^InputOr<(\w+)>$/.exec(type)) {
supported.push(`takes an input of type \`${match[1]}\``);
} else if (match = /^Argument<(.+)>( \| undefined)$/.exec(type)) {
supported.push(`takes an argument \`${name}\` of type \`${match[1]}\``);
} else if (name === "input") {
supported.push(`takes an input of type \`${type}\``);
} else if (name === "argument") {
supported.push(`accepts an argument of type \`${type}\``);
} else if (/^(Context|vscode.Text(Editor|Document))$/.test(type) || name === "selections") {
requiresActiveEditor = true;
}
}
if (!requiresActiveEditor) {
supported.push("does not require an active text editor");
}
return supported.sort();
}

1208
src/commands/README.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,205 +0,0 @@
// Changes
// https://github.com/mawww/kakoune/blob/master/doc/pages/keys.asciidoc#changes
import * as vscode from "vscode";
import { Command, CommandFlags, registerCommand } from ".";
registerCommand(Command.join, CommandFlags.Edit, () => {
return vscode.commands.executeCommand("editor.action.joinLines").then(() => void 0);
});
registerCommand(
Command.joinSelect,
CommandFlags.ChangeSelections | CommandFlags.Edit,
({ editor }, _, undoStops) => {
// Select all line endings.
const selections = editor.selections,
len = selections.length,
newSelections = [] as vscode.Selection[],
document = editor.document;
for (let i = 0; i < len; i++) {
const selection = selections[i],
startLine = selection.start.line,
endLine = selection.end.line,
startAnchor = new vscode.Position(startLine, Number.MAX_SAFE_INTEGER),
startActive = new vscode.Position(
startLine + 1,
document.lineAt(startLine + 1).firstNonWhitespaceCharacterIndex,
);
newSelections.push(new vscode.Selection(startAnchor, startActive));
for (let line = startLine + 1; line < endLine; line++) {
const anchor = new vscode.Position(line, Number.MAX_SAFE_INTEGER),
active = new vscode.Position(
line + 1,
document.lineAt(line + 1).firstNonWhitespaceCharacterIndex,
);
newSelections.push(new vscode.Selection(anchor, active));
}
}
editor.selections = newSelections;
// Replace all line endings by spaces.
return editor
.edit((builder) => {
for (const selection of editor.selections) {
builder.replace(selection, " ");
}
}, undoStops)
.then(() => void 0);
},
);
function getSelectionsLines(selections: vscode.Selection[]) {
const lines: number[] = [];
for (const selection of selections) {
const startLine = selection.start.line;
let endLine = selection.end.line;
if (startLine !== endLine && selection.end.character === 0) {
// If the selection ends after a line break, do not consider the next line
// selected. This is because a selection has to end on the very first
// caret position of the next line in order to select the last line break.
// For example, `vscode.TextLine.rangeIncludingLineBreak` does this:
// https://github.com/microsoft/vscode/blob/c8b27b9db6afc26cf82cf07a9653c89cdd930f6a/src/vs/workbench/api/common/extHostDocumentData.ts#L273
endLine--;
}
// The first and last lines of the selection may contain other selections,
// so we check for duplicates with them. However, the intermediate
// lines are known to belong to one selection only, so there's no need
// for that with them.
if (lines.indexOf(startLine) === -1) {
lines.push(startLine);
}
for (let i = startLine + 1; i < endLine; i++) {
lines.push(i);
}
if (lines.indexOf(endLine) === -1) {
lines.push(endLine);
}
}
return lines;
}
function indent(editor: vscode.TextEditor, ignoreEmpty: boolean) {
return editor
.edit((builder) => {
const indent = editor.options.insertSpaces === true
? " ".repeat(editor.options.tabSize as number)
: "\t";
for (const i of getSelectionsLines(editor.selections)) {
if (ignoreEmpty && editor.document.lineAt(i).isEmptyOrWhitespace) {
continue;
}
builder.insert(new vscode.Position(i, 0), indent);
}
})
.then(() => void 0);
}
registerCommand(Command.indent, CommandFlags.Edit, ({ editor }) => indent(editor, true));
registerCommand(Command.indentWithEmpty, CommandFlags.Edit, ({ editor }) => indent(editor, false));
function deindent(editor: vscode.TextEditor, repetitions: number, further: boolean) {
return editor
.edit((builder) => {
const doc = editor.document;
const tabSize = editor.options.tabSize as number;
// Number of blank characters needed to deindent:
const needed = repetitions * tabSize;
for (const i of getSelectionsLines(editor.selections)) {
const line = doc.lineAt(i),
text = line.text;
let column = 0, // Column, accounting for tab size
j = 0; // Index in source line, and number of characters to remove
for (; column < needed; j++) {
const char = text[j];
if (char === "\t") {
column += tabSize;
} else if (char === " ") {
column++;
} else {
break;
}
}
if (further && column === needed && j < text.length) {
// TODO
}
if (j !== 0) {
builder.delete(line.range.with(undefined, line.range.start.translate(0, j)));
}
}
})
.then(() => void 0);
}
registerCommand(Command.deindent, CommandFlags.Edit, ({ editor }, { repetitions }) =>
deindent(editor, repetitions, false),
);
registerCommand(Command.deindentFurther, CommandFlags.Edit, ({ editor }, { repetitions }) =>
deindent(editor, repetitions, true),
);
registerCommand(Command.toLowerCase, CommandFlags.Edit, ({ editor }) =>
editor
.edit((builder) => {
const doc = editor.document;
for (const selection of editor.selections) {
builder.replace(selection, doc.getText(selection).toLocaleLowerCase());
}
})
.then(() => void 0),
);
registerCommand(Command.toUpperCase, CommandFlags.Edit, ({ editor }) =>
editor
.edit((builder) => {
const doc = editor.document;
for (const selection of editor.selections) {
builder.replace(selection, doc.getText(selection).toLocaleUpperCase());
}
})
.then(() => void 0),
);
registerCommand(Command.swapCase, CommandFlags.Edit, ({ editor }) =>
editor
.edit((builder) => {
const doc = editor.document;
for (const selection of editor.selections) {
const text = doc.getText(selection);
let builtText = "";
for (let i = 0; i < text.length; i++) {
const x = text[i],
loCase = x.toLocaleLowerCase();
builtText += loCase === x ? x.toLocaleUpperCase() : loCase;
}
builder.replace(selection, builtText);
}
})
.then(() => void 0),
);

View File

@ -1,13 +0,0 @@
import { Command, CommandFlags, registerCommand } from ".";
for (let i = 0; i < 10; i++) {
const j = i;
registerCommand(
("dance.count." + j) as Command,
CommandFlags.IgnoreInHistory,
(_, { extension }) => {
extension.currentCount = extension.currentCount * 10 + j;
},
);
}

31
src/commands/dev.ts Normal file
View File

@ -0,0 +1,31 @@
import { Argument } from ".";
import { Extension } from "../state/extension";
import { SelectionBehavior } from "../state/modes";
/**
* Developer utilities for Dance.
*/
declare module "./dev";
/**
* Set the selection behavior of the specified mode.
*/
export function setSelectionBehavior(
extension: Extension,
mode: Argument<string>,
value?: Argument<"caret" | "character">,
) {
const selectedMode = extension.modes.get(mode);
if (selectedMode !== undefined) {
if (value === undefined) {
value = selectedMode.selectionBehavior === SelectionBehavior.Caret ? "character" : "caret";
}
selectedMode.update(
"_selectionBehavior",
value === "character" ? SelectionBehavior.Character : SelectionBehavior.Caret,
);
}
}

405
src/commands/edit.ts Normal file
View File

@ -0,0 +1,405 @@
import * as vscode from "vscode";
import * as api from "../api";
import {
Context,
deindentLines,
edit,
indentLines,
joinLines,
keypress,
LengthMismatchError,
replace,
Selections,
selectionsLines,
setSelections,
} from "../api";
import { Register } from "../state/registers";
import { Argument, InputOr, RegisterOr } from ".";
/**
* Perform changes on the text content of the document.
*
* See https://github.com/mawww/kakoune/blob/master/doc/pages/keys.asciidoc#changes.
*/
declare module "./edit";
/**
* Insert contents of register.
*
* A `where` argument may be specified to state where the text should be
* inserted relative to each selection. If unspecified, each selection will be
* replaced by the text.
*
* @keys `s-a-r` (normal)
*
* #### Additional commands
*
* | Title | Identifier | Keybinding | Commands |
* | ---------------------------------- | ----------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------- |
* | Pick register and replace | `selectRegister-insert` | `c-r` (normal), `c-r` (insert) | `[".selectRegister"], [".edit.insert"]` |
* | Paste before | `paste.before` | `s-p` (normal) | `[".edit.insert", { handleNewLine: true, where: "start" }]` |
* | Paste after | `paste.after` | `p` (normal) | `[".edit.insert", { handleNewLine: true, where: "end" }]` |
* | Paste before and select | `paste.before.select` | `s-a-p` (normal) | `[".edit.insert", { handleNewLine: true, where: "start", select: true }]` |
* | Paste after and select | `paste.after.select` | `a-p` (normal) | `[".edit.insert", { handleNewLine: true, where: "end" , select: true }]` |
* | Delete | `delete` | `a-d` (normal) | `[".edit.insert", { register: "_" }]` |
* | Delete and switch to Insert | `delete-insert` | `a-c` (normal) | `[".modes.set", { input: "insert" }], [".edit.insert", { register: "_" }]` |
* | Copy and delete | `yank-delete` | `d` (normal) | `[".selections.saveText"], [".edit.insert", { register: "_" }]` |
* | Copy and replace | `yank-replace` | `s-r` (normal) | `[".selections.saveText"], [".edit.insert"]` |
* | Copy, delete and switch to Insert | `yank-delete-insert` | `c` (normal) | `[".selections.saveText"], [".modes.set", { input: "insert" }], [".edit.insert", { register: "_" }]` |
*/
export async function insert(
_: Context,
selections: readonly vscode.Selection[],
register: RegisterOr<"dquote", Register.Flags.CanRead>,
adjust: Argument<boolean> = false,
handleNewLine: Argument<boolean> = false,
select: Argument<boolean> = false,
where?: Argument<"active" | "anchor" | "start" | "end" | undefined>,
) {
let contents = await register.get();
if (contents === undefined) {
throw new Error(`register "${register.name}" does not contain any saved text`);
}
if (adjust) {
contents = extendArrayToLength(contents, selections.length);
} else {
LengthMismatchError.throwIfLengthMismatch(selections, contents);
}
if (where === undefined) {
Selections.set(await replace.byIndex((i) => contents![i], selections));
return;
}
if (!["active", "anchor", "start", "end"].includes(where)) {
throw new Error(`"where" must be one of "active", "anchor", "start", "end", or undefined`);
}
const flags = api.insert.flagsAtEdge(where) | (select ? api.insert.Select : api.insert.Keep);
Selections.set(
handleNewLine
? await api.insert.byIndex.withFullLines(flags, (i) => contents![i], selections)
: await api.insert.byIndex(flags, (i) => contents![i], selections),
);
}
/**
* Join lines.
*
* @keys `a-j` (normal)
*/
export function join(_: Context, separator?: Argument<string>) {
return joinLines(selectionsLines(), separator);
}
/**
* Join lines and select inserted separators.
*
* @keys `s-a-j` (normal)
*/
export function join_select(_: Context, separator?: Argument<string>) {
return joinLines(selectionsLines(), separator).then(setSelections);
}
/**
* Indent selected lines.
*
* @keys `>` (normal)
*/
export function indent(_: Context, repetitions: number) {
return indentLines(selectionsLines(), repetitions, /* indentEmpty= */ false);
}
/**
* Indent selected lines (including empty lines).
*
* @keys `a->` (normal)
*/
export function indent_withEmpty(_: Context, repetitions: number) {
return indentLines(selectionsLines(), repetitions, /* indentEmpty= */ true);
}
/**
* Deindent selected lines.
*
* @keys `a-<` (normal)
*/
export function deindent(_: Context, repetitions: number) {
return deindentLines(selectionsLines(), repetitions, /* deindentIncomplete= */ false);
}
/**
* Deindent selected lines (including incomplete indent).
*
* @keys `<` (normal)
*/
export function deindent_withIncomplete(_: Context, repetitions: number) {
return deindentLines(selectionsLines(), repetitions, /* deindentIncomplete= */ true);
}
/**
* Transform to lower case.
*
* @keys `` ` `` (normal)
*/
export function case_toLower(_: Context) {
return replace((text) => text.toLocaleLowerCase());
}
/**
* Transform to upper case.
*
* @keys `` s-` `` (normal)
*/
export function case_toUpper(_: Context) {
return replace((text) => text.toLocaleUpperCase());
}
/**
* Swap case.
*
* @keys `` a-` `` (normal)
*/
export function case_swap(_: Context) {
return replace((text) => {
let builtText = "";
for (let i = 0, len = text.length; i < len; i++) {
const x = text[i],
loCase = x.toLocaleLowerCase();
builtText += loCase === x ? x.toLocaleUpperCase() : loCase;
}
return builtText;
});
}
/**
* Replace characters.
*
* @keys `r` (normal)
*/
export async function replaceCharacters(
_: Context,
repetitions: number,
inputOr: InputOr<string>,
) {
const input = (await inputOr(() => keypress(_))).repeat(repetitions);
return _.run(() => edit((editBuilder, selections, document) => {
for (const selection of selections) {
let i = selection.start.line;
if (selection.end.line === i) {
// A single line-selection; replace the selection directly
editBuilder.replace(
selection,
input!.repeat(selection.end.character - selection.start.character),
);
continue;
}
// Replace in first line
const firstLine = document.lineAt(i).range.with(selection.start);
editBuilder.replace(
firstLine,
input!.repeat(firstLine.end.character - firstLine.start.character),
);
// Replace in intermediate lines
while (++i < selection.end.line) {
const line = document.lineAt(i);
editBuilder.replace(line.range, input!.repeat(line.text.length));
}
// Replace in last line
const lastLine = document.lineAt(i).range.with(undefined, selection.end);
editBuilder.replace(
lastLine,
input!.repeat(lastLine.end.character - lastLine.start.character),
);
}
}));
}
/**
* Align selections.
*
* Align selections, aligning the cursor of each selection by inserting spaces
* before the first character of each selection.
*
* @keys `&` (normal)
*/
export function align(
_: Context,
selections: readonly vscode.Selection[],
fill: Argument<string> = " ",
) {
const startChar = selections.reduce(
(max, sel) => (sel.start.character > max ? sel.start.character : max),
0,
);
return edit((builder, selections) => {
for (let i = 0, len = selections.length; i < len; i++) {
const selection = selections[i];
builder.insert(selection.start, fill.repeat(startChar - selection.start.character));
}
});
}
/**
* Copy indentation.
*
* Copy the indentation of the main selection (or the count one if a count is
* given) to all other ones.
*
* @keys `a-&` (normal)
*/
export function copyIndentation(
_: Context,
document: vscode.TextDocument,
selections: readonly vscode.Selection[],
count: number,
) {
const sourceSelection = selections[count] ?? selections[0],
sourceIndent = document
.lineAt(sourceSelection.start)
.firstNonWhitespaceCharacterIndex;
return edit((builder, selections, document) => {
for (let i = 0, len = selections.length; i < len; i++) {
if (i === sourceSelection.start.line) {
continue;
}
const line = document.lineAt(selections[i].start),
indent = line.firstNonWhitespaceCharacterIndex;
if (indent > sourceIndent) {
builder.delete(
line.range.with(
undefined,
line.range.start.translate(undefined, indent - sourceIndent),
),
);
} else if (indent < sourceIndent) {
builder.insert(line.range.start, " ".repeat(indent - sourceIndent));
}
}
});
}
/**
* Insert new line above each selection.
*
* @keys `s-a-o` (normal)
*
* #### Additional keybindings
*
* | Title | Identifier | Keybinding | Commands |
* | ------------------------------------------ | ---------------------- | -------------- | -------------------------------------------------------------------------------- |
* | Insert new line above and switch to insert | `newLine.above.insert` | `s-o` (normal) | `[".modes.set", { input: "insert" }], [".edit.newLine.above", { select: true }]` |
*/
export function newLine_above(_: Context, select: Argument<boolean> = false) {
if (select) {
Selections.update.byIndex(prepareSelectionForLineInsertion);
// Use built-in `insertLineBefore` command. It gives us less control, but at
// least it handles indentation well.
return vscode.commands.executeCommand("editor.action.insertLineBefore");
}
return edit((builder, selections, document) => {
const newLine = document.eol === vscode.EndOfLine.LF ? "\n" : "\r\n",
processedLines = new Set<number>();
for (let i = 0, len = selections.length; i < len; i++) {
const selection = selections[i],
activeLine = Selections.activeLine(selection);
if (processedLines.size !== processedLines.add(activeLine).size) {
builder.insert(new vscode.Position(activeLine, 0), newLine);
}
}
});
}
/**
* Insert new line below each selection.
*
* @keys `a-o` (normal)
*
* #### Additional keybindings
*
* | Title | Identifier | Keybinding | Commands |
* | ------------------------------------------ | ---------------------- | ------------ | -------------------------------------------------------------------------------- |
* | Insert new line below and switch to insert | `newLine.below.insert` | `o` (normal) | `[".modes.set", { input: "insert" }], [".edit.newLine.below", { select: true }]` |
*/
export function newLine_below(_: Context, select: Argument<boolean> = false) {
if (select) {
Selections.update.byIndex(prepareSelectionForLineInsertion);
// Use built-in `insertLineAfter` command. It gives us less control, but at
// least it handles indentation well.
return vscode.commands.executeCommand("editor.action.insertLineAfter");
}
return edit((builder, selections, document) => {
const newLine = document.eol === vscode.EndOfLine.LF ? "\n" : "\r\n",
processedLines = new Set<number>();
for (let i = 0, len = selections.length; i < len; i++) {
const selection = selections[i],
activeLine = Selections.activeLine(selection);
if (processedLines.size !== processedLines.add(activeLine).size) {
builder.insert(new vscode.Position(activeLine + 1, 0), newLine);
}
}
});
}
function prepareSelectionForLineInsertion(_: number, selection: vscode.Selection) {
const activeLine = Selections.activeLine(selection);
if (selection.active.line !== activeLine) {
return new vscode.Selection(selection.anchor, selection.active.with(activeLine));
}
return selection;
}
function extendArrayToLength<T>(array: readonly T[], length: number) {
const arrayLen = array.length;
if (length > arrayLen) {
const newArray = [] as T[];
for (let i = 0; i < arrayLen; i++) {
newArray.push(array[i]);
}
const last = array[arrayLen - 1];
for (let i = arrayLen; i < length; i++) {
newArray.push(last);
}
return newArray;
} else {
return array.slice(0, length);
}
}

View File

@ -1,272 +0,0 @@
import * as path from "path";
import * as vscode from "vscode";
import { Command, CommandFlags, CommandState, InputKind, registerCommand } from ".";
import { EditorState } from "../state/editor";
import { SelectionBehavior } from "../state/extension";
import {
CoordMapper,
DoNotExtend,
Extend,
ExtendBehavior,
SelectionHelper,
jumpTo,
} from "../utils/selectionHelper";
const getMenu = (name: string) => (editorState: EditorState) => {
const menuItems = editorState.extension.menus.get(name)!.items;
return Object.entries(menuItems).map((x) => [x[0], x[1].text]) as [string, string][];
};
const executeMenuItem = async (editorState: EditorState, name: string, i: number) => {
const menuItems = editorState.extension.menus.get(name)!.items;
const menuItem = Object.values(menuItems)[i];
await vscode.commands.executeCommand(menuItem.command, menuItem.args);
};
// TODO: Make just merely opening the menu not count as a command execution
// and do not record it. The count+goto version (e.g. `10g`) should still count.
registerCommand(
Command.goto,
CommandFlags.ChangeSelections,
InputKind.ListOneItemOrCount,
getMenu("goto"),
(editorState, state) => {
if (state.input === null) {
const { editor } = editorState,
{ document } = editor;
let line = state.currentCount - 1;
if (line >= document.lineCount) {
line = document.lineCount - 1;
}
const active = new vscode.Position(line, 0),
anchor = new vscode.Position(line, 0);
editor.selections = [new vscode.Selection(anchor, active)];
return;
} else {
return executeMenuItem(editorState, "goto", state.input);
}
},
);
// TODO: Make just merely opening the menu not count as a command execution
// and do not record it. The count+goto version (e.g. `10G`) should still count.
registerCommand(
Command.gotoExtend,
CommandFlags.ChangeSelections,
InputKind.ListOneItemOrCount,
getMenu("goto.extend"),
(editorState, state) => {
if (state.input === null) {
const { editor } = editorState,
{ document, selection } = editor;
let line = state.currentCount - 1;
if (line >= document.lineCount) {
line = document.lineCount - 1;
}
const anchor = selection.anchor,
active = new vscode.Position(line, 0);
editor.selections = [new vscode.Selection(anchor, active)];
return;
} else {
return executeMenuItem(editorState, "goto.extend", state.input);
}
},
);
const toStartCharacterFunc: CoordMapper = (from, { editorState }, i) => {
editorState.preferredColumns[i] = 0;
return from.with(undefined, 0);
};
const toFirstNonBlankCharacterFunc: CoordMapper = (from, { editor, editorState }, i) => {
const column = editor.document.lineAt(from).firstNonWhitespaceCharacterIndex;
editorState.preferredColumns[i] = column;
return from.with(undefined, column);
};
const toEndCharacterFunc: CoordMapper = (from, helper, i) => {
const lineLen = helper.editor.document.lineAt(from).text.length;
helper.editorState.preferredColumns[i] = Number.MAX_SAFE_INTEGER;
if (lineLen === 0 || helper.selectionBehavior === SelectionBehavior.Caret) {
return from.with(undefined, lineLen);
} else {
return from.with(undefined, lineLen - 1);
}
};
function toCharacter(func: CoordMapper, extend: ExtendBehavior) {
const mapper = jumpTo(func, extend);
// TODO: Should also reveal selection active(s) after moving.
return (editorState: EditorState, commandState: CommandState) =>
SelectionHelper.for(editorState, commandState).mapEach(mapper);
}
registerCommand(
Command.gotoLineStart,
CommandFlags.ChangeSelections,
toCharacter(toStartCharacterFunc, DoNotExtend),
);
registerCommand(
Command.gotoLineStartExtend,
CommandFlags.ChangeSelections,
toCharacter(toStartCharacterFunc, Extend),
);
registerCommand(
Command.gotoLineStartNonBlank,
CommandFlags.ChangeSelections,
toCharacter(toFirstNonBlankCharacterFunc, DoNotExtend),
);
registerCommand(
Command.gotoLineStartNonBlankExtend,
CommandFlags.ChangeSelections,
toCharacter(toFirstNonBlankCharacterFunc, Extend),
);
registerCommand(
Command.gotoLineEnd,
CommandFlags.ChangeSelections,
toCharacter(toEndCharacterFunc, DoNotExtend),
);
registerCommand(
Command.gotoLineEndExtend,
CommandFlags.ChangeSelections,
toCharacter(toEndCharacterFunc, Extend),
);
const toFirstVisibleLineFunc: CoordMapper = (from, { editor }) =>
from.with(editor.visibleRanges[0].start.line, 0);
const toLastVisibleLineFunc: CoordMapper = (from, { editor }) =>
from.with(editor.visibleRanges[0].end.line, 0);
const toMiddleVisibleLineFunc: CoordMapper = (from, { editor }) =>
from.with(((editor.visibleRanges[0].end.line + editor.visibleRanges[0].start.line) / 2) | 0, 0);
registerCommand(
Command.gotoFirstVisibleLine,
CommandFlags.ChangeSelections,
toCharacter(toFirstVisibleLineFunc, DoNotExtend),
);
registerCommand(
Command.gotoFirstVisibleLineExtend,
CommandFlags.ChangeSelections,
toCharacter(toFirstVisibleLineFunc, Extend),
);
registerCommand(
Command.gotoMiddleVisibleLine,
CommandFlags.ChangeSelections,
toCharacter(toMiddleVisibleLineFunc, DoNotExtend),
);
registerCommand(
Command.gotoMiddleVisibleLineExtend,
CommandFlags.ChangeSelections,
toCharacter(toMiddleVisibleLineFunc, Extend),
);
registerCommand(
Command.gotoLastVisibleLine,
CommandFlags.ChangeSelections,
toCharacter(toLastVisibleLineFunc, DoNotExtend),
);
registerCommand(
Command.gotoLastVisibleLineExtend,
CommandFlags.ChangeSelections,
toCharacter(toLastVisibleLineFunc, Extend),
);
const toFirstLineFunc: CoordMapper = () => new vscode.Position(0, 0);
const toLastLineStartFunc: CoordMapper = (_, helper) => {
const document = helper.editor.document;
let line = document.lineCount - 1;
// In case of trailing line break, go to the second last line.
if (line > 0 && document.lineAt(document.lineCount - 1).text.length === 0) {
line--;
}
return new vscode.Position(line, 0);
};
// TODO: Also need to set preferredColumn to max.
const toLastLineEndFunc: CoordMapper = (_, helper) => {
const document = helper.editor.document;
const line = document.lineCount - 1;
const lineLen = document.lineAt(document.lineCount - 1).text.length;
return new vscode.Position(line, lineLen);
};
registerCommand(
Command.gotoFirstLine,
CommandFlags.ChangeSelections,
toCharacter(toFirstLineFunc, DoNotExtend),
);
registerCommand(
Command.gotoFirstLineExtend,
CommandFlags.ChangeSelections,
toCharacter(toFirstLineFunc, Extend),
);
registerCommand(
Command.gotoLastLine,
CommandFlags.ChangeSelections,
toCharacter(toLastLineStartFunc, DoNotExtend),
);
registerCommand(
Command.gotoLastLineExtend,
CommandFlags.ChangeSelections,
toCharacter(toLastLineStartFunc, Extend),
);
registerCommand(
Command.gotoLastCharacter,
CommandFlags.ChangeSelections,
toCharacter(toLastLineEndFunc, DoNotExtend),
);
registerCommand(
Command.gotoLastCharacterExtend,
CommandFlags.ChangeSelections,
toCharacter(toLastLineEndFunc, Extend),
);
registerCommand(Command.gotoSelectedFile, CommandFlags.ChangeSelections, ({ editor }) => {
const basePath = path.dirname(editor.document.fileName);
return Promise.all(editor.selections.map((selection) => {
const filename = editor.document.getText(selection),
filepath = path.resolve(basePath, filename);
return vscode.workspace.openTextDocument(filepath).then(vscode.window.showTextDocument);
})).then(() => void 0);
});
function toLastBufferModification(editorState: EditorState, extend: ExtendBehavior) {
const { documentState, editor } = editorState;
if (documentState.recordedChanges.length > 0) {
const range = documentState.recordedChanges[documentState.recordedChanges.length - 1].range,
selection = range.selection(documentState.document);
editor.selection = extend
? new vscode.Selection(editor.selection.anchor, selection.active)
: selection;
}
}
registerCommand(Command.gotoLastModification, CommandFlags.ChangeSelections, (editorState) =>
toLastBufferModification(editorState, DoNotExtend),
);
registerCommand(Command.gotoLastModificationExtend, CommandFlags.ChangeSelections, (editorState) =>
toLastBufferModification(editorState, Extend),
);

View File

@ -1,100 +1,198 @@
import * as vscode from "vscode";
import { Command, CommandDescriptor, CommandFlags, registerCommand } from ".";
import { Argument, CommandDescriptor, RegisterOr } from ".";
import { ArgumentError, Context } from "../api";
import { Register } from "../state/registers";
import { ActiveRecording, Recorder, Recording } from "../state/recorder";
registerCommand(
Command.historyUndo,
CommandFlags.ChangeSelections | CommandFlags.Edit | CommandFlags.IgnoreInHistory,
() => {
return vscode.commands.executeCommand("undo");
},
);
/**
* Interact with history.
*/
declare module "./history";
registerCommand(
Command.historyRedo,
CommandFlags.ChangeSelections | CommandFlags.Edit | CommandFlags.IgnoreInHistory,
() => {
return vscode.commands.executeCommand("redo");
},
);
/**
* Undo.
*
* @keys `u` (normal)
*/
export function undo() {
return vscode.commands.executeCommand("undo");
}
registerCommand(
Command.historyRepeat,
CommandFlags.ChangeSelections | CommandFlags.Edit | CommandFlags.IgnoreInHistory,
(editorState) => {
const commands = editorState.recordedCommands;
/**
* Redo.
*
* @keys `s-u` (normal)
*/
export function redo() {
return vscode.commands.executeCommand("redo");
}
if (commands.length === 0) {
return;
/**
* Undo a change of selections.
*
* @keys `a-u` (normal)
*/
export function undo_selections() {
return vscode.commands.executeCommand("cursorUndo");
}
/**
* Redo a change of selections.
*
* @keys `s-a-u` (normal)
*/
export function redo_selections() {
return vscode.commands.executeCommand("cursorRedo");
}
/**
* Repeat last change.
*
* @noreplay
*
* | Title | Identifier | Keybinding | Commands |
* | ---------------------------- | ------------------ | -------------- | --------------------------------------------------------------------------- |
* | Repeat last selection change | `repeat.selection` | | `[".history.repeat", { include: "dance\\.(seek|select|selections)\\..+" }]` |
* | Repeat last seek | `repeat.seek` | `a-.` (normal) | `[".history.repeat", { include: "dance\\.seek\\..+" }]` |
*/
export async function repeat(
_: Context,
repetitions: number,
include: Argument<string | RegExp> = /.+/,
) {
if (typeof include === "string") {
include = new RegExp(include, "u");
}
let commandDescriptor: CommandDescriptor,
commandArgument: object;
const cursor = _.extension.recorder.cursorFromEnd();
for (;;) {
if (cursor.is(Recording.ActionType.Command)
&& include.test(cursor.commandDescriptor().identifier)) {
commandDescriptor = cursor.commandDescriptor();
commandArgument = cursor.commandArgument();
break;
}
const lastCommandState = commands[commands.length - 1];
return CommandDescriptor.execute(editorState, lastCommandState);
},
);
registerCommand(
Command.historyRepeatSelection,
CommandFlags.ChangeSelections | CommandFlags.IgnoreInHistory,
(editorState) => {
const commands = editorState.recordedCommands;
for (let i = commands.length - 1; i >= 0; i--) {
const commandState = commands[i];
if (
commandState.descriptor.flags & CommandFlags.ChangeSelections
&& !(commandState.descriptor.flags & CommandFlags.Edit)
) {
return CommandDescriptor.execute(editorState, commandState);
}
}
return;
},
);
registerCommand(
Command.historyRepeatEdit,
CommandFlags.Edit | CommandFlags.IgnoreInHistory,
(editorState) => {
const commands = editorState.recordedCommands;
for (let i = commands.length - 1; i >= 0; i--) {
const commandState = commands[i];
if (commandState.descriptor.flags & CommandFlags.Edit) {
return CommandDescriptor.execute(editorState, commandState);
}
}
return;
},
);
const ObjectOrSelectToCommands = new Set([
Command.objectsPerformSelection,
Command.selectToExcluded,
Command.selectToExcludedBackwards,
Command.selectToExcludedExtend,
Command.selectToExcludedExtendBackwards,
Command.selectToIncluded,
Command.selectToIncludedBackwards,
Command.selectToIncludedExtend,
Command.selectToIncludedExtendBackwards,
]);
registerCommand(Command.repeatObjectOrSelectTo, CommandFlags.ChangeSelections, (editorState) => {
const commands = editorState.recordedCommands;
for (let i = commands.length - 1; i >= 0; i--) {
const commandState = commands[i];
if (ObjectOrSelectToCommands.has(commandState.descriptor.command)) {
return CommandDescriptor.execute(editorState, commandState);
if (!cursor.previous()) {
throw new Error("no previous command matching " + include);
}
}
return undefined;
});
for (let i = 0; i < repetitions; i++) {
await commandDescriptor.replay(_, commandArgument);
}
}
/**
* Repeat last edit without a command.
*
* @keys `.` (normal)
* @noreplay
*/
export async function repeat_edit(_: Context, repetitions: number) {
const recorder = _.extension.recorder,
cursor = recorder.cursorFromEnd();
let startCursor: Recorder.Cursor | undefined,
endCursor: Recorder.Cursor | undefined;
for (;;) {
if (cursor.is(Recording.ActionType.Command)
&& cursor.commandDescriptor().identifier === "dance.modes.set") {
const modeName = cursor.commandArgument().input as string;
if (modeName === "normal") {
endCursor = cursor.clone();
} else if (modeName === "insert" && endCursor !== undefined) {
startCursor = cursor.clone();
break;
}
}
if (!cursor.previous()) {
throw new Error("cannot find switch to normal or insert mode");
}
}
// TODO: almost there, but not completely
for (let i = 0; i < repetitions; i++) {
for (let cursor = startCursor.clone(); cursor.isBeforeOrEqual(endCursor); cursor.next()) {
await cursor.replay(_);
}
}
}
/**
* Replay recording.
*
* @keys `q` (normal)
* @noreplay
*/
export async function recording_play(
_: Context.WithoutActiveEditor,
repetitions: number,
register: RegisterOr<"arobase", Register.Flags.CanReadWriteMacros>,
) {
const recording = register.getRecording();
ArgumentError.validate(
"recording",
recording !== undefined,
() => `register "${register.name}" does not hold a recording`,
);
for (let i = 0; i < repetitions; i++) {
await recording.replay(_);
}
}
const recordingPerRegister = new WeakMap<Register, ActiveRecording>();
/**
* Start recording.
*
* @keys `s-q` (normal)
* @noreplay
*/
export function recording_start(
_: Context,
register: RegisterOr<"arobase", Register.Flags.CanReadWriteMacros>,
) {
ArgumentError.validate(
"register",
!recordingPerRegister.has(register),
"a recording is already active",
);
const recording = _.extension.recorder.startRecording();
recordingPerRegister.set(register, recording);
}
/**
* Stop recording.
*
* @keys `escape` (normal, recording)
* @noreplay
*/
export function recording_stop(
_: Context,
register: RegisterOr<"arobase", Register.Flags.CanReadWriteMacros>,
) {
const recording = recordingPerRegister.get(register);
ArgumentError.validate(
"register",
recording !== undefined,
"no recording is active in the given register",
);
recordingPerRegister.delete(register);
register.setRecording(recording.complete());
}

View File

@ -1,534 +1,183 @@
import * as vscode from "vscode";
import { Register } from "../registers";
import { EditorState } from "../state/editor";
import { Extension, Mode, SelectionBehavior } from "../state/extension";
import { keypress, prompt, promptInList, promptRegex } from "../utils/prompt";
import { Command } from "../../commands";
import { DocumentStart } from "../utils/selectionHelper";
import { Context, EditorRequiredError, insertUndoStop } from "../api";
import { Extension } from "../state/extension";
import { Register, Registers } from "../state/registers";
export import Command = Command;
/**
* Indicates that a register is expected; if no register is given, the
* specified default should be given instead.
*/
export type RegisterOr<_Default extends keyof Registers,
Flags extends Register.Flags = Register.Flags.None>
= Register.WithFlags<Flags>;
export const enum CommandFlags {
/** No particular flags. */
None = 0,
/** Switch to normal mode after operation. */
SwitchToNormal = 1 << 1,
/** Switch to insert mode after operation before the cursor. */
SwitchToInsertBefore = 1 << 2,
/** Switch to insert mode after operation after the cursor. */
SwitchToInsertAfter = 1 << 3,
/** Restore previous mode after operation. */
RestoreMode = 1 << 4,
/** Ignores the command in history. */
IgnoreInHistory = 1 << 5,
/** Edits the content of the editor. */
Edit = 1 << 6,
/** Changes the current selections. */
ChangeSelections = 1 << 7,
/** Do not reset preferred columns for moving up and down. */
DoNotResetPreferredColumns = 1 << 8,
/** Allow the command to be run without an active editor. */
CanRunWithoutEditor = 1 << 9,
}
export class CommandState<Input extends InputKind = any> {
private readonly _followingChanges = [] as vscode.TextDocumentContentChangeEvent[];
/**
* The number of times that a command should be repeated.
*
* Equivalent to `currentCount === 0 ? 1 : currentCount`.
*/
public get repetitions() {
const count = this.currentCount;
return count === 0 ? 1 : count;
}
/**
* The insert-mode changes that were recorded after invoking this command.
*/
public get followingChanges() {
return this._followingChanges as readonly vscode.TextDocumentContentChangeEvent[];
}
public readonly currentCount: number;
public readonly currentRegister?: Register;
public readonly selectionBehavior: SelectionBehavior;
public constructor(
public readonly descriptor: CommandDescriptor<Input>,
public readonly input: InputTypeMap[Input],
public readonly extension: Extension,
public readonly argument: any,
) {
this.currentCount = extension.currentCount;
this.currentRegister = extension.currentRegister;
this.selectionBehavior = extension.selectionBehavior;
}
/**
* Records the given changes as having happened after this command.
*/
public recordFollowingChanges(changes: readonly vscode.TextDocumentContentChangeEvent[]) {
this._followingChanges.push(...changes);
}
}
export interface UndoStops {
readonly undoStopBefore: boolean;
readonly undoStopAfter: boolean;
}
export type Action<Input extends InputKind> = (
editorState: EditorState,
state: CommandState<Input>,
undoStops: UndoStops,
) => void | Thenable<void | undefined>;
export const enum InputKind {
None,
RegExp,
ListOneItem,
ListManyItems,
Text,
Key,
ListOneItemOrCount,
}
export interface InputTypeMap {
readonly [InputKind.None]: void;
readonly [InputKind.RegExp]: RegExp;
readonly [InputKind.ListOneItem]: number;
readonly [InputKind.ListManyItems]: number[];
readonly [InputKind.Text]: string;
readonly [InputKind.Key]: string;
readonly [InputKind.ListOneItemOrCount]: number | null;
}
export interface InputDescrMap {
readonly [InputKind.None]: undefined;
readonly [InputKind.ListOneItem]: [string, string][];
readonly [InputKind.ListManyItems]: [string, string][];
readonly [InputKind.RegExp]: string;
readonly [InputKind.Text]: vscode.InputBoxOptions & {
readonly setup?: (editorState: EditorState) => void;
readonly onDidCancel?: (editorState: EditorState) => void;
};
readonly [InputKind.Key]: undefined;
readonly [InputKind.ListOneItemOrCount]: [string, string][];
/**
* Indicates that an input is expected; if no input is given, the specified
* function will be used to update the input value in subsequent executions of
* this command.
*/
export interface InputOr<T> {
(promptDefaultInput: () => T): T;
(promptDefaultInput: () => Thenable<T>): Thenable<T>;
}
/**
* Defines a command's behavior, as well as its inputs.
* Indicates that an input is expected.
*/
export class CommandDescriptor<Input extends InputKind = InputKind> {
/**
* Whether errors in command executions should lead to hard exceptions.
*/
public static throwOnError = false;
export type Input<T> = T | undefined;
/**
* A function used to update the input value in subsequent executions of this
* command.
*/
export interface SetInput<T> {
(input: T): void;
}
/**
* Indicates that a value passed as a command argument is expected.
*/
export type Argument<T> = T;
/**
* The type of a `Context` passed to a command, based on whether the command
* requires an active text editor or not.
*/
export type ContextType<RequiresActiveEditor extends boolean = boolean>
= RequiresActiveEditor extends true ? Context : Context.WithoutActiveEditor;
/**
* The type of the handler of a `CommandDescriptor`.
*/
export interface Handler<RequiresActiveEditor extends boolean = boolean> {
(context: ContextType<RequiresActiveEditor>,
argument: Record<string, any>): unknown | Thenable<unknown>;
}
/**
* The descriptor of a command.
*/
export class CommandDescriptor<Flags extends CommandDescriptor.Flags = CommandDescriptor.Flags> {
public get requiresActiveEditor() {
return (this.flags & CommandDescriptor.Flags.RequiresActiveEditor) !== 0;
}
public constructor(
public readonly command: Command,
public readonly flags: CommandFlags,
public readonly input: Input,
public readonly inputDescr: (editorState: EditorState) => InputDescrMap[Input],
public readonly action: Action<Input>,
/**
* The unique identifier of the command.
*/
public readonly identifier: string,
/**
* The handler of the command.
*/
public readonly handler: Handler<Flags extends CommandDescriptor.Flags.RequiresActiveEditor
? true : false>,
/**
* The flags of the command.
*/
public readonly flags: Flags,
) {
Object.freeze(this);
}
/**
* Returns the the input for the current command, after requesting it from the
* user if the given argument does not already specify it.
* Executes the command with the given argument.
*/
public async getInput(
editorState: EditorState,
argument: any,
cancellationToken?: vscode.CancellationToken,
): Promise<InputTypeMap[Input] | undefined> {
let input: InputTypeMap[Input] | undefined;
switch (this.input) {
case InputKind.RegExp:
if (typeof argument === "object" && argument?.input instanceof RegExp) {
input = argument.input;
} else if (typeof argument === "object" && typeof argument.input === "string") {
input = new RegExp(argument.input, this.inputDescr(editorState) as string) as any;
} else {
input = await promptRegex(this.inputDescr(editorState) as string, cancellationToken) as any;
}
break;
case InputKind.ListOneItem:
if (typeof argument === "object" && typeof argument.input === "string") {
input = argument.input;
} else {
input = await promptInList(
false, this.inputDescr(editorState) as [string, string][], cancellationToken) as any;
}
break;
case InputKind.ListOneItemOrCount:
if (typeof argument === "object" && typeof argument.input === "string") {
input = argument.input;
} else if (editorState.extension.currentCount === 0) {
input = await promptInList(
false, this.inputDescr(editorState) as [string, string][], cancellationToken) as any;
} else {
input = null as any;
}
break;
case InputKind.ListManyItems:
if (typeof argument === "object" && typeof argument.input === "string") {
input = argument.input;
} else {
input = await promptInList(
true, this.inputDescr(editorState) as [string, string][], cancellationToken) as any;
}
break;
case InputKind.Text:
const inputDescr = this.inputDescr(editorState) as InputDescrMap[InputKind.Text];
if (inputDescr.setup !== undefined) {
inputDescr.setup(editorState);
}
if (typeof argument === "object" && typeof argument.input === "string") {
const error = await Promise.resolve(inputDescr.validateInput?.(argument.input));
if (error) {
throw new Error(`invalid text input: ${error}`);
}
input = argument.input;
} else {
input = await prompt(inputDescr, cancellationToken) as any;
}
break;
case InputKind.Key:
if (typeof argument === "object" && typeof argument.input === "string") {
input = argument.input;
} else {
const prevMode = editorState.mode;
editorState.setMode(Mode.Awaiting);
input = await keypress(cancellationToken) as any;
editorState.setMode(prevMode);
}
break;
}
return input;
public replay(context: ContextType<Flags extends CommandDescriptor.Flags.RequiresActiveEditor
? true : false>, argument: Record<string, any>) {
return this.handler(context, argument);
}
/**
* Executes the command completely, prompting the user for input and saving
* history entries if needed.
* Invokes the command with the given argument.
*/
public async execute(editorState: EditorState, argument: any) {
const { extension, editor } = editorState;
public async invoke(extension: Extension, argument: unknown) {
const context = Context.create(extension, this);
if (editor.selections.length === 0) {
// Most commands won't work without any selection at all. So let's force
// one. This mainly happens when document is empty.
editor.selections = [new vscode.Selection(DocumentStart, DocumentStart)];
if (this.requiresActiveEditor && !(context instanceof Context)) {
throw new EditorRequiredError();
}
const flags = this.flags;
const cts = new vscode.CancellationTokenSource();
const ownedArgument = Object.assign({}, argument) as Record<string, unknown>;
extension.cancellationTokenSource?.cancel();
extension.cancellationTokenSource = cts;
const input = await this.getInput(editorState, argument, cts.token);
if (this.input !== InputKind.None && input === undefined) {
const inputDescr = this.inputDescr?.(editorState);
if (typeof inputDescr === "object" && "onDidCancel" in inputDescr) {
inputDescr.onDidCancel?.(editorState);
}
return;
if (ownedArgument.count === undefined && extension.currentCount !== 0) {
ownedArgument.count = extension.currentCount;
}
if (ownedArgument.register === undefined && extension.currentRegister !== undefined) {
ownedArgument.register = extension.currentRegister;
}
if (
this.flags & CommandFlags.ChangeSelections
&& !(this.flags & CommandFlags.DoNotResetPreferredColumns)
) {
editorState.preferredColumns.length = 0;
}
extension.currentCount = 0;
extension.currentRegister = undefined;
const commandState = new CommandState<Input>(
this,
input as InputTypeMap[Input],
extension,
argument,
);
let result: unknown;
if (!this.command.startsWith("dance.count.")) {
extension.currentCount = 0;
}
editorState.ignoreSelectionChanges = true;
let result;
try {
result = this.action(editorState, commandState, {
undoStopBefore: true,
undoStopAfter: true,
});
if (result !== undefined) {
if (typeof result === "object" && typeof result.then === "function") {
result = await result;
}
}
result = await this.handler(context as any, ownedArgument);
} catch (e) {
if (CommandDescriptor.throwOnError) {
console.error(e);
throw e;
}
let message = e;
if (typeof e === "object" && e !== null && e.constructor === Error) {
// Note that we purposedly do not use `instanceof` above to keep
// prefixes like "SyntaxError:".
message = e.message;
}
// Or show error in the status bar? VSCode does not have a way to dismiss
// messages, but they recommend setting the status bar instead.
// See: https://github.com/Microsoft/vscode/issues/2732
vscode.window.showErrorMessage(`Error executing command "${this.command}": ${message}`);
return;
}
if (flags & (CommandFlags.SwitchToInsertBefore | CommandFlags.SwitchToInsertAfter)) {
// Ensure selections face the right way.
const selections = editor.selections,
len = selections.length,
shouldBeReversed = (flags & CommandFlags.SwitchToInsertBefore) !== 0;
for (let i = 0; i < len; i++) {
const selection = selections[i];
selections[i]
= selection.isReversed === shouldBeReversed
? selection
: new vscode.Selection(selection.active, selection.anchor);
}
// Make selections empty.
editorState.setMode(Mode.Insert);
for (let i = 0; i < len; i++) {
const position = flags & CommandFlags.SwitchToInsertBefore
? selections[i].start
: selections[i].end;
selections[i] = new vscode.Selection(position, position);
}
editor.selections = selections;
} else if (flags & CommandFlags.SwitchToNormal) {
editorState.setMode(Mode.Normal);
}
if (flags & CommandFlags.ChangeSelections) {
// Scroll to cursor if needed
const position = editor.selection.active;
editor.revealRange(new vscode.Range(position, position));
}
if (remainingNormalCommands === 1) {
remainingNormalCommands = 0;
editorState.setMode(Mode.Insert);
} else if (remainingNormalCommands > 1) {
remainingNormalCommands--;
}
editorState.ignoreSelectionChanges = false;
editorState.recordCommand(commandState);
editorState.normalizeSelections();
}
/**
* Executes the given command using the given state.
*/
public static async execute<I extends InputKind>(
editorState: EditorState,
commandState: CommandState<I>,
) {
const { editor } = editorState;
let result = commandState.descriptor.action(editorState, commandState, {
undoStopBefore: true,
undoStopAfter: true,
});
if (result !== undefined) {
if (typeof result === "object" && typeof result.then === "function") {
result = await result;
}
if (typeof result === "function") {
await editor.edit(result);
}
}
}
/**
* Executes the given commands as part of a batch operation started by a
* macro, for example.
*/
public static async executeMany(
editorState: EditorState,
commands: readonly CommandState<any>[],
) {
// In a batch execution, we don't change modes, and some things are not
// prompted again.
// Furthermore, a single entry is added to VS Code's history for the entire
// batch operation.
let firstEditIdx = 0,
lastEditIdx = commands.length - 1;
for (let i = 0; i < commands.length; i++) {
if (commands[i].descriptor.flags & CommandFlags.Edit) {
firstEditIdx = i;
break;
}
}
for (let i = commands.length - 1; i >= 0; i--) {
if (commands[i].descriptor.flags & CommandFlags.Edit) {
lastEditIdx = i;
break;
}
}
let currentMode = editorState.mode;
const { editor } = editorState;
for (let i = 0; i < commands.length; i++) {
const commandState = commands[i],
descriptor = commandState.descriptor;
const undoStops = {
undoStopBefore: i === firstEditIdx,
undoStopAfter: i === lastEditIdx,
};
let result = descriptor.action(editorState, commandState, undoStops);
if (result !== undefined) {
if (typeof result === "object" && typeof result.then === "function") {
result = await result;
}
if (typeof result === "function") {
await editor.edit(result, undoStops);
}
}
if (
descriptor.flags
& (CommandFlags.SwitchToInsertBefore | CommandFlags.SwitchToInsertAfter)
) {
currentMode = Mode.Insert;
} else if (descriptor.flags & CommandFlags.SwitchToNormal) {
currentMode = Mode.Normal;
}
}
editorState.setMode(currentMode);
}
public register(extension: Extension) {
if (this.flags & CommandFlags.CanRunWithoutEditor) {
return vscode.commands.registerCommand(this.command, (arg) => {
const editor = vscode.window.activeTextEditor;
if (editor === undefined) {
// @ts-ignore
return this.action(undefined, new CommandState(this, undefined, extension, arg));
}
return this.execute(extension.getEditorState(editor).updateEditor(editor), arg);
});
}
return vscode.commands.registerCommand(this.command, (arg) => {
const editor = vscode.window.activeTextEditor;
if (editor === undefined) {
if ((ownedArgument as { readonly try: boolean }).try) {
return;
}
return this.execute(extension.getEditorState(editor).updateEditor(editor), arg);
});
throw e;
}
// Record command *after* executing it, to ensure it did not encounter
// an error.
extension.recorder.recordCommand(this, ownedArgument);
if (this.requiresActiveEditor) {
await (context as Context).insertUndoStop();
}
return result;
}
/**
* Invokes the command with the given argument, ensuring that errors are
* reporting to the user instead of throwing them.
*/
public invokeSafely(extension: Extension, argument: unknown) {
return extension.runPromiseSafely(
() => this.invoke(extension, argument),
() => undefined,
(e) => `error executing command "${this.identifier}": ${e.message}`,
);
}
/**
* Registers the command for use by VS Code.
*/
public register(extension: Extension): vscode.Disposable {
return vscode.commands.registerCommand(
this.identifier,
(argument) => this.invokeSafely(extension, argument),
);
}
}
export const commands: CommandDescriptor<any>[] = [];
export const commandsByName: Record<Command, CommandDescriptor<any>> = {} as any;
export namespace CommandDescriptor {
/**
* Flags describing the behavior of some commands.
*/
export const enum Flags {
/** No specific behavior. */
None = 0b0000,
export const preferredColumnsPerEditor = new WeakMap<vscode.TextEditor, number[]>();
export let remainingNormalCommands = 0;
/** An active editor must be available. */
RequiresActiveEditor = 0b0001,
export function registerCommand(
command: Command,
flags: CommandFlags,
action: Action<InputKind.None>,
): void;
export function registerCommand<Input extends InputKind>(
command: Command,
flags: CommandFlags,
input: Input,
inputDescr: (editorState: EditorState) => InputDescrMap[Input],
action: Action<Input>,
): void;
export function registerCommand(...args: readonly any[]) {
const descriptor = args.length === 3
? new CommandDescriptor(args[0], args[1], InputKind.None, () => void 0, args[2])
: new CommandDescriptor(args[0], args[1], args[2], args[3], args[4]);
commands.push(descriptor);
commandsByName[args[0] as Command] = descriptor;
/** The command should not be replayed in macros and repeats. */
DoNotReplay = 0b0010,
}
}
export function setRemainingNormalCommands(remaining: number) {
remainingNormalCommands = remaining + 1;
// ^^^ to account for the currently
// executing command.
/**
* A record from command identifier to command descriptor.
*/
export interface Commands {
readonly [commandIdentifier: string]: CommandDescriptor;
}
import "./changes";
import "./count";
import "./goto";
import "./history";
import "./insert";
import "./macros";
import "./mark";
import "./menus";
import "./misc";
import "./modes";
import "./move";
import "./pipe";
import "./rotate";
import "./search";
import "./select";
import "./selections";
import "./selectObject";
import "./yankPaste";
Object.freeze(commandsByName);

View File

@ -1,208 +0,0 @@
import * as vscode from "vscode";
import { Command, CommandDescriptor, CommandFlags, registerCommand } from ".";
import { SelectionBehavior } from "../state/extension";
import { SelectionHelper } from "../utils/selectionHelper";
registerCommand(
Command.insertBefore,
CommandFlags.ChangeSelections | CommandFlags.SwitchToInsertBefore,
() => {
// Nop.
},
);
registerCommand(
Command.insertAfter,
CommandFlags.ChangeSelections | CommandFlags.SwitchToInsertAfter,
() => {
// Nop.
},
);
registerCommand(
Command.insertLineStart,
CommandFlags.ChangeSelections | CommandFlags.SwitchToInsertBefore,
({ editor }) => {
const selections = editor.selections,
len = selections.length;
for (let i = 0; i < len; i++) {
const selection = selections[i],
lineStart = editor.document.lineAt(selection.start.line)
.firstNonWhitespaceCharacterIndex;
selections[i] = new vscode.Selection(
selection.anchor,
new vscode.Position(selection.start.line, lineStart),
);
}
editor.selections = selections;
},
);
registerCommand(
Command.insertLineEnd,
CommandFlags.ChangeSelections | CommandFlags.SwitchToInsertAfter,
({ editor }) => {
const selections = editor.selections,
len = selections.length;
for (let i = 0; i < len; i++) {
const selection = selections[i];
selections[i] = new vscode.Selection(
selection.anchor,
new vscode.Position(selection.end.line, Number.MAX_SAFE_INTEGER),
);
}
editor.selections = selections;
},
);
function normalizeSelectionsForLineInsertion(editor: vscode.TextEditor) {
editor.selections = editor.selections.map((selection) => {
let { active } = selection;
if (active.character === 0 && !selection.isReversed && active.line > 0) {
active = active.translate(-1);
}
return new vscode.Selection(active, active);
});
}
registerCommand(
Command.insertNewLineAbove,
CommandFlags.Edit | CommandFlags.SwitchToInsertBefore,
({ editor }, { selectionBehavior }) => {
if (selectionBehavior === SelectionBehavior.Character) {
normalizeSelectionsForLineInsertion(editor);
}
return vscode.commands.executeCommand("editor.action.insertLineBefore");
},
);
registerCommand(
Command.insertNewLineBelow,
CommandFlags.Edit | CommandFlags.SwitchToInsertBefore,
({ editor }, { selectionBehavior }) => {
if (selectionBehavior === SelectionBehavior.Character) {
normalizeSelectionsForLineInsertion(editor);
}
return vscode.commands.executeCommand("editor.action.insertLineAfter");
},
);
registerCommand(Command.newLineAbove, CommandFlags.Edit, (editorState, state, undoStops) =>
editorState.editor
.edit((builder) => {
const { editor } = editorState;
const newLine = editor.document.eol === vscode.EndOfLine.LF ? "\n" : "\r\n";
const processedLines = new Set<number>();
const selections = editor.selections,
len = selections.length,
selectionHelper = SelectionHelper.for(editorState, state);
for (let i = 0; i < len; i++) {
const selection = selections[i],
activeLine
= selection.active === selection.end
? selectionHelper.endLine(selection)
: selection.active.line;
if (processedLines.size !== processedLines.add(activeLine).size) {
builder.insert(new vscode.Position(activeLine, 0), newLine);
}
}
}, undoStops)
.then(() => void 0),
);
registerCommand(Command.newLineBelow, CommandFlags.Edit, (editorState, state, undoStops) =>
editorState.editor
.edit((builder) => {
const { editor } = editorState;
const newLine = editor.document.eol === vscode.EndOfLine.LF ? "\n" : "\r\n";
const processedLines = new Set<number>();
const selections = editor.selections,
len = selections.length,
selectionHelper = SelectionHelper.for(editorState, state);
for (let i = 0; i < len; i++) {
const selection = selections[i],
activeLine
= selection.active === selection.end
? selectionHelper.endLine(selection)
: selection.active.line;
if (processedLines.size !== processedLines.add(activeLine).size) {
builder.insert(new vscode.Position(activeLine + 1, 0), newLine);
}
}
}, undoStops)
.then(() => void 0),
);
registerCommand(Command.repeatInsert, CommandFlags.Edit, async ({ editor }, state) => {
const editorState = state.extension.getEditorState(editor);
let switchToInsert: undefined | typeof editorState.recordedCommands[0];
let i = editorState.recordedCommands.length - 1;
for (; i >= 0; i--) {
if (editorState.recordedCommands[i].descriptor.flags & CommandFlags.SwitchToInsertBefore) {
switchToInsert = editorState.recordedCommands[i];
break;
}
}
if (switchToInsert === undefined) {
return;
}
const start = i;
let switchToNormal: undefined | typeof editorState.recordedCommands[0];
for (i++; i < editorState.recordedCommands.length; i++) {
if (editorState.recordedCommands[i].descriptor.flags & CommandFlags.SwitchToNormal) {
switchToNormal = editorState.recordedCommands[i];
break;
}
}
if (switchToNormal === undefined) {
return;
}
await CommandDescriptor.execute(editorState, editorState.recordedCommands[start]);
const end = i;
await editor.edit((builder) => {
for (let i = state.currentCount || 1; i > 0; i--) {
for (let j = start; j <= end; j++) {
const commandState = editorState.recordedCommands[j],
changes = commandState.followingChanges;
if (changes === undefined) {
continue;
}
for (const change of changes) {
if (change.rangeLength === 0) {
builder.insert(editor.selection.active, change.text);
} else {
builder.replace(editor.selection, change.text);
}
}
}
}
});
});

View File

@ -0,0 +1,33 @@
import * as vscode from "vscode";
import { RegisterOr } from ".";
import { Context, prompt, todo } from "../api";
import { Register } from "../state/registers";
/**
* Utilities for setting up keybindings.
*/
declare module "./keybindings";
/**
* Set up Dance keybindings.
*/
export async function setup(_: Context, register: RegisterOr<"dquote", Register.Flags.CanWrite>) {
await vscode.commands.executeCommand("workbench.action.openGlobalKeybindingsFile");
await _.switchToDocument(_.extension.editors.active!.editor.document);
const action = await prompt.one([
["y", "yank keybindings to register"],
["a", "append keybindings"],
["p", "prepend keybindings"],
]);
if (typeof action === "string") {
return;
}
const keybindings = await prompt.many([
["d", "default keybindings"],
]);
todo();
}

View File

@ -0,0 +1,199 @@
import * as assert from "assert";
import { Builder, unindent } from "../../meta";
export async function build(builder: Builder) {
const modules = await builder.getCommandModules();
return unindent(4, `
${modules.map((module) => unindent(8, `
/**
* Loads the "${module.name}" module and returns its defined commands.
*/
async function load${capitalize(module.name!)}Module(): Promise<CommandDescriptor[]> {
const {${
module.functionNames
.map((name) => "\n" + " ".repeat(16) + name + ",")
.sort()
.join("")}
} = await import("./${module.name}");
return [${
module.functions
.map((f) => `
new CommandDescriptor(
"dance.${f.qualifiedName}",
${determineFunctionExpression(f)},
${determineFunctionFlags(f)},
),`)
.sort()
.join("")}${
module.additional.concat(...module.functions.map((f) => f.additional))
.filter((x) => x.identifier !== undefined && x.commands !== undefined)
.map((x) => `
new CommandDescriptor(
"dance.${x.qualifiedIdentifier}",
${buildCommandsExpression(x)},
CommandDescriptor.Flags.RequiresActiveEditor | CommandDescriptor.Flags.DoNotReplay,
),`)
.sort()
.join("")}
];
}
`).trim()).join("\n\n")}
/**
* Loads and returns all defined commands.
*/
export async function loadCommands(): Promise<Commands> {
const allModules = await Promise.all([${
modules
.map((module) => `\n${" ".repeat(8)}load${capitalize(module.name!)}Module(),`)
.join("")}
]);
return Object.freeze(
Object.fromEntries(allModules.flat().map((desc) => [desc.identifier, desc])),
);
}
`);
}
function capitalize(text: string) {
return text.replace(/(\.|^)[a-z]/g, (x, dot) => x.slice(dot.length).toUpperCase());
}
function determineFunctionExpression(f: Builder.ParsedFunction) {
const givenParameters: string[] = [];
let takeArgument = false;
for (const [name, type] of f.parameters) {
switch (name) {
// Arguments, input.
case "argument":
takeArgument = true;
givenParameters.push("argument");
break;
case "input":
takeArgument = true;
givenParameters.push("getInput(argument)");
break;
case "setInput":
takeArgument = true;
givenParameters.push("getSetInput(argument)");
break;
case "inputOr":
takeArgument = true;
givenParameters.push("getInputOr(argument)");
break;
case "direction":
takeArgument = true;
givenParameters.push("getDirection(argument)");
break;
case "shift":
takeArgument = true;
givenParameters.push("getShift(argument)");
break;
// Implicit context.
case "_":
givenParameters.push("_");
break;
// Context (without active editor).
case "cancellationToken":
givenParameters.push("_.cancellationToken");
break;
case "extension":
givenParameters.push("_.extension");
break;
case "modes":
givenParameters.push("_.extension.modes");
break;
case "registers":
givenParameters.push("_.extension.registers");
break;
case "count":
takeArgument = true;
givenParameters.push("getCount(_, argument)");
break;
case "repetitions":
takeArgument = true;
givenParameters.push("getRepetitions(_, argument)");
break;
case "register":
takeArgument = true;
const match = /^RegisterOr<"(\w+)"(?:, (.+))>$/.exec(type);
assert(match !== null);
const flags = match[2] ?? "Register.Flags.None",
registerString = `getRegister(_, argument, ${JSON.stringify(match[1])}, ${flags})`;
givenParameters.push(registerString);
break;
// Context (with active editor).
case "document":
givenParameters.push("_.document");
break;
case "selections":
givenParameters.push("_.selections");
break;
// ??
default:
if (type.startsWith("Context")) {
givenParameters.push("_");
} else if (type.startsWith("Argument<")) {
takeArgument = true;
givenParameters.push("argument." + name);
} else {
throw new Error(`unknown parameter ${JSON.stringify([name, type])}`);
}
}
}
const inputParameters = ["_", ...(takeArgument ? ["argument"] : [])],
call = `${f.name}(${givenParameters.join(", ")})`;
return `(${inputParameters.join(", ")}) => _.runAsync((_) => ${call})`;
}
function determineFunctionFlags(f: Builder.ParsedFunction) {
const flags = [] as string[];
if (f.parameters.some(([_, t]) => ["Context"].includes(t))
|| f.parameters.some(([p]) => ["document", "selections"].includes(p))) {
flags.push("RequiresActiveEditor");
}
if ("noreplay" in f.properties) {
flags.push("DoNotReplay");
}
if (flags.length === 0) {
return "CommandDescriptor.Flags.None";
}
return flags.map((flag) => "CommandDescriptor.Flags." + flag).join(" | ");
}
function buildCommandsExpression(f: Builder.AdditionalCommand) {
const commands = f.commands!.replace(/ +/g, " ").replace(/ \}\]/g, ", ...argument }]");
return `(_, argument) => _.runAsync(() => commands(${commands}))`;
}

1289
src/commands/load-all.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,49 +0,0 @@
import { Command, CommandDescriptor, CommandFlags, registerCommand } from ".";
import { MacroRegister, Register } from "../registers";
registerCommand(
Command.macrosRecordStart,
CommandFlags.IgnoreInHistory,
(editorState, { currentRegister, extension }) => {
const reg
= ((currentRegister as any) as MacroRegister & Register) ?? extension.registers.arobase;
if (typeof reg.setMacro === "function") {
return editorState.startMacroRecording(reg)?.then(() => void 0);
}
return;
},
);
registerCommand(
Command.macrosRecordStop,
CommandFlags.SwitchToNormal | CommandFlags.IgnoreInHistory,
(editorState) => {
return editorState.stopMacroRecording()?.then((recording) => {
const commands = editorState.recordedCommands.slice(recording.lastHistoryEntry);
recording.register.setMacro(
commands.filter((x) => !(x.descriptor.flags & CommandFlags.IgnoreInHistory)),
);
});
},
);
registerCommand(
Command.macrosPlay,
CommandFlags.ChangeSelections | CommandFlags.Edit,
(editorState, { currentRegister, extension, repetitions }) => {
const reg = ((currentRegister as any) as MacroRegister) ?? extension.registers.arobase;
if (typeof reg.getMacro === "function") {
const commands = reg.getMacro();
if (commands !== undefined) {
for (let i = repetitions; i > 0; i--) {
CommandDescriptor.executeMany(editorState, commands);
}
}
}
},
);

View File

@ -1,201 +0,0 @@
// Marks
// https://github.com/mawww/kakoune/blob/master/doc/pages/keys.asciidoc#marks
import * as vscode from "vscode";
import { Command, CommandFlags, InputKind, registerCommand } from ".";
import { Register } from "../registers";
import { SavedSelection } from "../utils/savedSelection";
registerCommand(
Command.registersSelect,
CommandFlags.IgnoreInHistory,
InputKind.Key,
() => void 0,
(_, { extension, input: key }) => {
extension.currentRegister = extension.registers.get(key);
},
);
const marksByRegister = new Map<
Register,
WeakMap<vscode.TextDocument, readonly SavedSelection[]>
>();
function marksForRegister(register: Register) {
let map = marksByRegister.get(register);
if (map === undefined) {
marksByRegister.set(register, (map = new WeakMap()));
}
return map;
}
registerCommand(
Command.marksSaveSelections,
CommandFlags.None,
({ editor, extension, documentState }, { currentRegister }) => {
const map = marksForRegister(currentRegister ?? extension.registers.caret),
existingMarks = map.get(editor.document);
if (existingMarks !== undefined) {
documentState.forgetSelections(existingMarks);
}
map.set(
editor.document,
editor.selections.map((selection) => documentState.saveSelection(selection)),
);
},
);
registerCommand(
Command.marksRestoreSelections,
CommandFlags.ChangeSelections,
({ editor, extension }, { currentRegister }) => {
const map = marksForRegister(currentRegister ?? extension.registers.caret),
marks = map.get(editor.document);
if (marks !== undefined) {
editor.selections = marks.map((savedSelection) => savedSelection.selection(editor.document));
}
},
);
const combineOpts: () => [string, string][] = () => [
["a", "Append lists"],
["u", "Union"],
["i", "Intersection"],
["<", "Select leftmost cursor"],
[">", "Select rightmost cursor"],
["+", "Select longest"],
["-", "Select shortest"],
];
function combineSelections(
editor: vscode.TextEditor,
from: vscode.Selection[],
add: vscode.Selection[],
type: number,
) {
if (type === 0) {
editor.selections = from.concat(add);
return;
}
if (from.length !== add.length) {
throw new Error("the current and marked selections have different sizes");
}
const selections = [] as vscode.Selection[];
for (let i = 0; i < from.length; i++) {
const a = from[i],
b = add[i];
switch (type) {
case 1: {
const anchor = a.start.isBefore(b.start) ? a.start : b.start,
active = a.end.isAfter(b.end) ? a.end : b.end;
selections.push(new vscode.Selection(anchor, active));
break;
}
case 2: {
const anchor = a.start.isAfter(b.start) ? a.start : b.start,
active = a.end.isBefore(b.end) ? a.end : b.end;
selections.push(new vscode.Selection(anchor, active));
break;
}
case 3:
if (a.active.isBeforeOrEqual(b.active)) {
selections.push(a);
} else {
selections.push(b);
}
break;
case 4:
if (a.active.isAfterOrEqual(b.active)) {
selections.push(a);
} else {
selections.push(b);
}
break;
case 5: {
const aLength = editor.document.offsetAt(a.end) - editor.document.offsetAt(a.start),
bLength = editor.document.offsetAt(b.end) - editor.document.offsetAt(b.start);
if (aLength > bLength) {
selections.push(a);
} else {
selections.push(b);
}
break;
}
case 6: {
const aLength = editor.document.offsetAt(a.end) - editor.document.offsetAt(a.start),
bLength = editor.document.offsetAt(b.end) - editor.document.offsetAt(b.start);
if (aLength < bLength) {
selections.push(a);
} else {
selections.push(b);
}
break;
}
}
}
editor.selections = selections;
}
registerCommand(
Command.marksCombineSelectionsFromCurrent,
CommandFlags.ChangeSelections,
InputKind.ListOneItem,
combineOpts,
({ editor, extension }, { currentRegister, input }) => {
const map = marksForRegister(currentRegister ?? extension.registers.caret),
marks = map.get(editor.document);
if (marks === undefined) {
return;
}
combineSelections(
editor,
editor.selections,
marks.map((savedSelection) => savedSelection.selection(editor.document)),
input,
);
},
);
registerCommand(
Command.marksCombineSelectionsFromRegister,
CommandFlags.ChangeSelections,
InputKind.ListOneItem,
combineOpts,
({ editor, extension }, { currentRegister, input }) => {
const map = marksForRegister(currentRegister ?? extension.registers.caret),
marks = map.get(editor.document);
if (marks === undefined) {
return;
}
combineSelections(
editor,
marks.map((savedSelection) => savedSelection.selection(editor.document)),
editor.selections,
input,
);
},
);

View File

@ -1,55 +0,0 @@
import * as vscode from "vscode";
import { CommandFlags, registerCommand } from ".";
import { Extension } from "../state/extension";
import { promptInList } from "../utils/prompt";
import { Command } from "../../commands";
registerCommand(
Command.openMenu,
CommandFlags.CanRunWithoutEditor,
(_, { argument, extension }) => {
if (typeof argument !== "object" || argument === null || typeof argument.menu !== "string") {
throw new Error(`invalid argument`);
}
const menuName = argument.menu;
if (Object.keys(argument).length > 1) {
const argFields = Object.assign({}, argument);
delete argFields.menu;
openMenu(menuName, extension, argFields);
} else {
openMenu(menuName, extension);
}
},
);
export async function openMenu(
menuName: string,
extension: Extension,
argFields?: Record<string, any>,
) {
const menu = extension.menus.get(menuName);
if (menu === undefined) {
throw new Error(`menu ${JSON.stringify(menuName)} does not exist`);
}
const entries = Object.entries(menu.items);
const items = entries.map((x) => [x[0], x[1].text]) as [string, string][];
const choice = await promptInList(false, items, extension.cancellationTokenSource?.token);
if (choice === undefined) {
return;
}
const pickedItem = entries[choice][1];
let args = pickedItem.args ?? [];
if (argFields) {
args = [Object.assign({}, argFields, args[0]), ...args.slice(1)];
}
await vscode.commands.executeCommand(pickedItem.command, ...args);
}

View File

@ -1,128 +1,219 @@
import * as vscode from "vscode";
import {
Command,
CommandDescriptor,
CommandFlags,
CommandState,
InputKind,
commandsByName,
registerCommand,
} from ".";
import * as api from "../api";
registerCommand(Command.cancel, CommandFlags.IgnoreInHistory, () => {
// Nop, because the caller cancels everything before calling us.
});
import { Argument, InputOr } from ".";
import { Context, InputError, keypress, Menu, prompt, showLockedMenu, showMenu, validateMenu } from "../api";
import { Extension } from "../state/extension";
import { Register } from "../state/registers";
type RunFunction = (vscodeObj: typeof vscode, danceObj: object, args: any) => Promise<any> | any;
type RunFunctionConstructor =
(vscodeObj: "vscode", danceObj: "dance", args: "args", code: string) => RunFunction;
/**
* Miscellaneous commands that don't deserve their own category.
*
* By default, Dance also exports the following keybindings for existing
* commands:
*
* | Keybinding | Command |
* | -------------- | ----------------------------------- |
* | `s-;` (normal) | `["workbench.action.showCommands"]` |
*/
declare module "./misc";
const AsyncFunction = async function () {}.constructor as RunFunctionConstructor;
/**
* Cancel Dance operation.
*
* @keys `escape` (normal), `escape` (input)
*/
export function cancel(extension: Extension) {
// Calling a new command resets pending operations, so we don't need to do
// anything special here.
extension.cancelLastOperation();
}
registerCommand(Command.run, CommandFlags.IgnoreInHistory, async (editorState, state) => {
let code = state.argument?.code;
/**
* Ignore key.
*/
export function ignore() {
// Used to intercept and ignore key presses in a given mode.
}
let lastRunCode: string | undefined;
/**
* Run code.
*/
export async function run(
_: Context,
inputOr: InputOr<string | readonly string[]>,
commands?: Argument<api.command.Any[]>,
) {
if (Array.isArray(commands)) {
return api.commands(...commands);
}
let code = await inputOr(() => prompt({
prompt: "Code to run",
validateInput(value) {
try {
api.run.compileFunction(value);
return;
} catch (e) {
if (e instanceof SyntaxError) {
return `invalid syntax: ${e.message}`;
}
return e?.message ?? `${e}`;
}
},
value: lastRunCode,
valueSelection: lastRunCode === undefined ? undefined : [0, lastRunCode.length],
}, _));
if (Array.isArray(code)) {
code = code.join("\n");
} else if (typeof code !== "string") {
throw new Error(`expected code to be a string or an array, but it was ${code}`);
return new InputError(`expected code to be a string or an array, but it was ${code}`);
}
let func: RunFunction;
return _.run(() => api.run(code as string));
}
try {
func = AsyncFunction("vscode", "dance", "args", code) as any;
} catch (e) {
throw new Error(`cannot parse function body: ${code}: ${e}`);
/**
* Select register for next command.
*
* When selecting a register, the next key press is used to determine what
* register is selected. If this key is a `space` character, then a new key
* press is awaited again and the returned register will be specific to the
* current document.
*
* @keys `"` (normal)
*/
export async function selectRegister(_: Context, inputOr: InputOr<string | Register>) {
const input = await inputOr(() => keypress.forRegister(_));
if (typeof input === "string") {
if (input.length === 0) {
return;
}
const extension = _.extension,
registers = extension.registers;
extension.currentRegister = input.startsWith(" ")
? registers.forDocument(_.document).get(input.slice(1))
: registers.get(input);
} else {
_.extension.currentRegister = input;
}
}
/**
* Update Dance count.
*
* Update the current counter used to repeat the next command.
*
* #### Additional keybindings
*
* | Title | Keybinding | Command |
* | ------------------------------ | ------------ | ------------------------------------ |
* | Add the digit 0 to the counter | `0` (normal) | `[".updateCount", { addDigits: 0 }]` |
* | Add the digit 1 to the counter | `1` (normal) | `[".updateCount", { addDigits: 1 }]` |
* | Add the digit 2 to the counter | `2` (normal) | `[".updateCount", { addDigits: 2 }]` |
* | Add the digit 3 to the counter | `3` (normal) | `[".updateCount", { addDigits: 3 }]` |
* | Add the digit 4 to the counter | `4` (normal) | `[".updateCount", { addDigits: 4 }]` |
* | Add the digit 5 to the counter | `5` (normal) | `[".updateCount", { addDigits: 5 }]` |
* | Add the digit 6 to the counter | `6` (normal) | `[".updateCount", { addDigits: 6 }]` |
* | Add the digit 7 to the counter | `7` (normal) | `[".updateCount", { addDigits: 7 }]` |
* | Add the digit 8 to the counter | `8` (normal) | `[".updateCount", { addDigits: 8 }]` |
* | Add the digit 9 to the counter | `9` (normal) | `[".updateCount", { addDigits: 9 }]` |
*/
export async function updateCount(
_: Context,
count: number,
extension: Extension,
inputOr: InputOr<number>,
addDigits?: Argument<number>,
) {
if (typeof addDigits === "number") {
let nextPowerOfTen = 1;
if (addDigits <= 0) {
addDigits = 0;
nextPowerOfTen = 10;
}
while (nextPowerOfTen <= addDigits) {
nextPowerOfTen *= 10;
}
extension.currentCount = count * nextPowerOfTen + addDigits;
return;
}
const danceInterface = Object.freeze({
async execute(...commands: any[]) {
const batches = [] as (readonly CommandState[] | [string, any])[],
currentBatch = [] as CommandState[],
extension = editorState.extension;
const input = +await inputOr(() => prompt.number({ integer: true, range: [0, 1_000_000] }, _));
if (commands.length === 2 && typeof commands[0] === "string") {
commands = [commands];
InputError.validateInput(!isNaN(input), "value is not a number");
InputError.validateInput(input >= 0, "value is negative");
extension.currentCount = input;
}
let lastPickedMenu: string | undefined;
/**
* Open menu.
*
* If no input is specified, a prompt will ask for the name of the menu to open.
*
* Alternatively, a `menu` can be inlined in the arguments.
*
* Pass a `prefix` argument to insert the prefix string followed by the typed
* key if it does not match any menu entry. This can be used to implement chords
* like `jj`.
*/
export async function openMenu(
_: Context.WithoutActiveEditor,
inputOr: InputOr<string>,
menu?: Argument<Menu>,
prefix?: Argument<string>,
pass: Argument<any[]> = [],
locked: Argument<boolean> = false,
) {
if (typeof menu === "object") {
const errors = validateMenu(menu);
if (errors.length > 0) {
throw new Error(`invalid menu: ${errors.join(", ")}`);
}
if (locked) {
return showLockedMenu(menu, pass);
}
return showMenu(menu, pass, prefix);
}
const menus = _.extension.menus;
const input = await inputOr(() => prompt({
prompt: "Menu name",
validateInput(value) {
if (menus.has(value)) {
lastPickedMenu = value;
return;
}
// Build and validate commands.
for (let i = 0, len = commands.length; i < len; i++) {
let commandName: string,
commandArgument: any;
const command = commands[i];
if (typeof command === "string") {
commandName = command;
commandArgument = undefined;
} else if (Array.isArray(command) && command.length === 1) {
if (typeof command[0] !== "string") {
throw new Error("the first element of an execute tuple must be a command name");
}
commandName = command[0];
commandArgument = undefined;
} else if (Array.isArray(command) && command.length === 2) {
if (typeof command[0] !== "string") {
throw new Error("the first element of an execute tuple must be a command name");
}
commandName = command[0];
commandArgument = command[1];
} else {
throw new Error(
"execute arguments must be command names or [command name, argument] tuples",
);
}
if (commandName.startsWith(".")) {
commandName = `dance${commandName}`;
}
if (commandName in commandsByName) {
const descriptor = commandsByName[commandName as Command],
input = await descriptor.getInput(
editorState,
commandArgument,
extension.cancellationTokenSource?.token);
if (descriptor.input !== InputKind.None && input === undefined) {
return;
}
currentBatch.push(new CommandState(descriptor, input, extension, commandArgument));
} else {
if (commandName.startsWith("dance.")) {
throw new Error(`Dance command ${JSON.stringify(commandName)} does not exist`);
}
if (currentBatch.length > 0) {
batches.push(currentBatch.splice(0));
}
batches.push([commandName, commandArgument]);
}
}
if (currentBatch.length > 0) {
batches.push(currentBatch);
}
// Execute all commands.
for (const batch of batches) {
if (typeof batch[0] === "string") {
await vscode.commands.executeCommand(batch[0], batch[1]);
} else {
await CommandDescriptor.executeMany(editorState, batch);
}
}
return `menu ${JSON.stringify(value)} does not exist`;
},
});
placeHolder: [...menus.keys()].sort().join(", ") || "no menu defined",
value: lastPickedMenu,
}, _));
try {
await func(vscode, danceInterface, state.argument);
} catch (e) {
throw new Error(`code threw an exception: ${e}`);
if (locked) {
return showLockedMenu.byName(input, pass);
}
});
return showMenu.byName(input, pass, prefix);
}

View File

@ -1,31 +1,62 @@
import * as vscode from "vscode";
import { Command, CommandFlags, registerCommand, setRemainingNormalCommands } from ".";
import { Mode } from "../state/extension";
import { InputOr } from ".";
import { Context, prompt, toMode } from "../api";
registerCommand(Command.setInsert, CommandFlags.SwitchToInsertBefore, () => {
// Nop.
});
/**
* Set modes.
*/
declare module "./modes";
registerCommand(Command.setNormal, CommandFlags.SwitchToNormal, () => {
// Nop.
});
/**
* Set Dance mode.
*
* #### Variants
*
* | Title | Identifier | Keybinding | Command |
* | ------------------ | ------------ | ----------------- | ------------------------------------- |
* | Set mode to Normal | `set.normal` | `escape` (insert) | `[".modes.set", { input: "normal" }]` |
* | Set mode to Insert | `set.insert` | | `[".modes.set", { input: "insert" }]` |
*
* Other variants are provided to switch to insert mode:
*
* | Title | Identifier | Keybinding | Commands |
* | -------------------- | ------------------ | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
* | Insert before | `insert.before` | `i` (normal) | `[".selections.faceBackward"], [".modes.set", { input: "insert" }], [".selections.reduce", { where: "start" }]` |
* | Insert after | `insert.after` | `a` (normal) | `[".selections.faceForward"] , [".modes.set", { input: "insert" }], [".selections.reduce", { where: "end" }]` |
* | Insert at line start | `insert.lineStart` | `s-i` (normal) | `[".select.lineStart", { shift: "jump", skipBlank: true }], [".modes.set", { input: "insert" }], [".selections.reduce", { where: "start" }]` |
* | Insert at line end | `insert.lineEnd` | `s-a` (normal) | `[".select.lineEnd" , { shift: "jump" }], [".modes.set", { input: "insert" }], [".selections.reduce", { where: "end" }]` |
*/
export async function set(_: Context, inputOr: InputOr<string>) {
await toMode(await inputOr(() => prompt(validateModeName())));
}
registerCommand(
Command.tmpInsert,
CommandFlags.SwitchToInsertBefore,
(editorState, { repetitions }) => {
const subscription = vscode.commands.registerCommand("type", (...args) => {
if (--repetitions === 0) {
subscription.dispose();
editorState.setMode(Mode.Normal);
/**
* Set Dance mode temporarily.
*
* #### Variants
*
* | Title | Identifier | Keybindings | Commands |
* | --------------------- | ------------------------ | -------------- | ------------------------------------------------- |
* | Temporary Normal mode | `set.temporarily.normal` | `c-v` (insert) | `[".modes.set.temporarily", { input: "normal" }]` |
* | Temporart Insert mode | `set.temporarily.insert` | `c-v` (normal) | `[".modes.set.temporarily", { input: "insert" }]` |
*/
export async function set_temporarily(_: Context, inputOr: InputOr<string>, repetitions: number) {
await toMode(await inputOr(() => prompt(validateModeName())), repetitions);
}
function validateModeName(ctx = Context.WithoutActiveEditor.current) {
const modes = ctx.extension.modes;
return {
prompt: "Mode name",
validateInput(value) {
if (modes.get(value) !== undefined) {
return;
}
return vscode.commands.executeCommand("default:type", ...args);
});
},
);
registerCommand(Command.tmpNormal, CommandFlags.SwitchToNormal, (_, { repetitions }) => {
setRemainingNormalCommands(repetitions);
});
return `mode ${JSON.stringify(value)} does not exist`;
},
placeHolder: [...modes.userModes()].map((m) => m.name).sort().join(", "),
} as vscode.InputBoxOptions;
}

View File

@ -1,135 +0,0 @@
// Movement
// https://github.com/mawww/kakoune/blob/master/doc/pages/keys.asciidoc#movement
import * as vscode from "vscode";
import { Command, CommandFlags, registerCommand } from ".";
import {
Backward,
Direction,
DoNotExtend,
Extend,
ExtendBehavior,
Forward,
jumpTo,
} from "../utils/selectionHelper";
import { Coord, SelectionHelper } from "../utils/selectionHelper";
import { SelectionBehavior } from "../state/extension";
// Move around (h, j, k, l, H, J, K, L, arrows, shift+arrows)
// ===============================================================================================
function revealActiveTowards(direction: Direction, editor: vscode.TextEditor) {
let revealPosition = undefined as vscode.Position | undefined;
for (let i = 0; i < editor.selections.length; i++) {
const activePosition = editor.selections[i].active;
if (revealPosition === undefined || revealPosition.compareTo(activePosition) * direction > 0) {
revealPosition = activePosition;
}
}
editor.revealRange(new vscode.Range(revealPosition!, revealPosition!));
}
function registerMoveHorizontal(command: Command, direction: Direction, extend: ExtendBehavior) {
const selectionMapper = jumpTo((from, helper) => {
return helper.coordAt(helper.offsetAt(from) + helper.state.repetitions * direction);
}, extend);
registerCommand(command, CommandFlags.ChangeSelections, (editorState, state) => {
SelectionHelper.for(editorState, state).mapEach(selectionMapper);
revealActiveTowards(direction, editorState.editor);
});
}
function registerMoveVertical(command: Command, direction: Direction, extend: ExtendBehavior) {
const selectionMapper = jumpTo((from, helper, i) => {
const targetLine = from.line + helper.state.repetitions * direction;
let actualLine = targetLine;
if (actualLine < 0) {
actualLine = 0;
} else if (targetLine > helper.editor.document.lineCount - 1) {
actualLine = helper.editor.document.lineCount - 1;
}
const lineLen = helper.editor.document.lineAt(actualLine).text.length;
if (lineLen === 0) {
// Select the line break on an empty line.
return new Coord(actualLine, 0);
}
const preferredColumn = helper.editorState.preferredColumns![i];
if (preferredColumn >= lineLen) {
if (helper.selectionBehavior === SelectionBehavior.Character) {
return new Coord(actualLine, lineLen - 1);
} else {
return new Coord(actualLine, lineLen);
}
}
return new Coord(actualLine, preferredColumn);
}, extend);
registerCommand(
command,
CommandFlags.ChangeSelections | CommandFlags.DoNotResetPreferredColumns,
(editorState, state) => {
const { editor, preferredColumns } = editorState,
selectionHelper = SelectionHelper.for(editorState, state);
if (preferredColumns.length === 0) {
for (let i = 0; i < editor.selections.length; i++) {
const column = selectionHelper.activeCoord(editor.selections[i]).character;
preferredColumns.push(column);
}
}
SelectionHelper.for(editorState, state).mapEach(selectionMapper);
revealActiveTowards(direction, editorState.editor);
},
);
}
// Move/extend left/down/up/right
registerMoveHorizontal(Command.left, Backward, DoNotExtend);
registerMoveHorizontal(Command.leftExtend, Backward, Extend);
registerMoveHorizontal(Command.right, Forward, DoNotExtend);
registerMoveHorizontal(Command.rightExtend, Forward, Extend);
registerMoveVertical(Command.up, Backward, DoNotExtend);
registerMoveVertical(Command.upExtend, Backward, Extend);
registerMoveVertical(Command.down, Forward, DoNotExtend);
registerMoveVertical(Command.downExtend, Forward, Extend);
// Move up/down (ctrl-[bfud])
// ===============================================================================================
function scrollBy(iterations: number, to: "up" | "down", by: "page" | "halfPage") {
return vscode.commands.executeCommand("editorScroll", {
to,
by,
value: iterations,
revealCursor: true,
}) as Promise<void>;
}
registerCommand(Command.upPage, CommandFlags.ChangeSelections, (_, { repetitions }) =>
scrollBy(repetitions, "up", "page"),
);
registerCommand(Command.upHalfPage, CommandFlags.ChangeSelections, (_, { repetitions }) =>
scrollBy(repetitions, "up", "halfPage"),
);
registerCommand(Command.downPage, CommandFlags.ChangeSelections, (_, { repetitions }) =>
scrollBy(repetitions, "down", "page"),
);
registerCommand(Command.downHalfPage, CommandFlags.ChangeSelections, (_, { repetitions }) =>
scrollBy(repetitions, "down", "halfPage"),
);
// Other bindings (%)
// ===============================================================================================
registerCommand(Command.selectBuffer, CommandFlags.ChangeSelections, ({ editor }) => {
const start = new vscode.Position(0, 0);
const end = editor.document.lineAt(editor.document.lineCount - 1).range.end;
editor.selection = new vscode.Selection(start, end);
});

View File

@ -1,355 +0,0 @@
// Pipes
// https://github.com/mawww/kakoune/blob/master/doc/pages/keys.asciidoc#changes-through-external-programs
import * as cp from "child_process";
import * as vscode from "vscode";
import { Command, CommandFlags, InputKind, registerCommand } from ".";
import { SelectionHelper } from "../utils/selectionHelper";
function getShell() {
let os: string;
if (process.platform === "cygwin") {
os = "linux";
} else if (process.platform === "linux") {
os = "linux";
} else if (process.platform === "darwin") {
os = "osx";
} else if (process.platform === "win32") {
os = "windows";
} else {
return undefined;
}
const config = vscode.workspace.getConfiguration("terminal");
return (
config.get<string | null>(`integrated.automationShell.${os}`) ?? process.env.SHELL ?? undefined
);
}
function execWithInput(command: string, input: string) {
return new Promise<{ readonly val: string } | { readonly err: string }>((resolve) => {
const shell = getShell() ?? true,
child = cp.spawn(command, { shell, stdio: "pipe" });
let stdout = "",
stderr = "";
child.stdout.on("data", (chunk: Buffer) => (stdout += chunk.toString("utf-8")));
child.stderr.on("data", (chunk: Buffer) => (stderr += chunk.toString("utf-8")));
child.stdin.end(input, "utf-8");
child.once("error", (err) => resolve({ err: err.message }));
child.once("exit", (code) =>
code === 0
? resolve({ val: stdout.trimRight() })
: resolve({
err: `Command exited with error ${code}: ${
stderr.length > 0 ? stderr.trimRight() : "<No error output>"
}`,
}),
);
});
}
function parseRegExp(regexp: string) {
if (regexp.length < 3 || regexp[0] !== "/") {
return "Invalid RegExp.";
}
let pattern = "";
let replacement: string | undefined = undefined;
let flags: string | undefined = undefined;
for (let i = 1; i < regexp.length; i++) {
const ch = regexp[i];
if (flags !== undefined) {
// Parse flags
if (ch !== "m" && ch !== "i" && ch !== "g") {
return `Unknown flag '${ch}'.`;
}
flags += ch;
} else if (replacement !== undefined) {
// Parse replacement string
if (ch === "/") {
flags = "";
} else if (ch === "\\") {
if (i === regexp.length - 1) {
return "Unexpected end of RegExp.";
}
replacement += ch + regexp[++i];
} else {
replacement += ch;
}
} else {
// Parse pattern
if (ch === "/") {
replacement = "";
} else if (ch === "\\") {
if (i === regexp.length - 1) {
return "Unexpected end of RegExp.";
}
pattern += ch + regexp[++i];
} else {
pattern += ch;
}
}
}
try {
return [new RegExp(pattern, flags), replacement] as [RegExp, string | undefined];
} catch {
return "Invalid RegExp.";
}
}
function pipe(command: string, selections: string[]) {
if (command.startsWith("#")) {
// Shell
command = command.substr(1);
return Promise.all(selections.map((selection) => execWithInput(command, selection)));
} else if (command.startsWith("/")) {
// RegExp replace (note that it's safe to call parseRegExp since the input
// is validated)
const [regexp, replacement] = parseRegExp(command) as [RegExp, string];
return Promise.resolve(selections.map((x) => ({ val: x.replace(regexp, replacement) })));
} else {
// JavaScript
const funct = new Function("$", "i", "$$", "return " + command) as (
$: string,
i: number,
$$: string[],
) => any;
return Promise.resolve(
selections.map(($, i, $$) => {
let result: any;
try {
result = funct($, i, $$);
} catch {
return { err: "Exception thrown in given expression." };
}
if (result === null) {
return { val: "null" };
}
if (result === undefined) {
return { val: "" };
}
if (typeof result === "string") {
return { val: result };
}
if (typeof result === "number" || typeof result === "boolean") {
return { val: result.toString() };
}
if (typeof result === "object") {
return { val: JSON.stringify(result) };
}
return { err: "Invalid returned value." };
}),
);
}
}
function displayErrors(errors: { err?: string }[]) {
let message = "";
let errorCount = 0;
for (const error of errors) {
if (error.err !== undefined && error.err.length > 0) {
message += `- "${error.err}".`;
errorCount++;
}
}
if (errorCount === 0) {
return false;
}
if (errorCount === 1) {
message = `Error running shell command: ${message.substr(2)}`;
} else {
message = `Errors running shell command:\n${message}`;
}
vscode.window.showErrorMessage(message);
return true;
}
const getInputBoxOptions = (expectReplacement: boolean) =>
({
validateInput(input) {
if (input.trim().length === 0) {
return "The given command cannot be empty.";
}
if (input[0] === "/") {
const result = parseRegExp(input);
if (typeof result === "string") {
return result;
}
if (expectReplacement && result[1] === undefined) {
return "Missing replacement part in RegExp.";
}
return;
}
if (input[0] === "#") {
if (input.substr(1).trim().length === 0) {
return "The given shell command cannot be empty.";
}
return;
}
try {
new Function("$", "$$", "i", "return " + input);
} catch {
return "Invalid expression.";
}
return undefined;
},
prompt: "Enter an expression",
} as vscode.InputBoxOptions);
const inputBoxOptions = getInputBoxOptions(false);
const inputBoxOptionsWithReplacement = getInputBoxOptions(true);
const getInputBoxOptionsWithoutReplacement = () => inputBoxOptions;
const getInputBoxOptionsWithReplacement = () => inputBoxOptionsWithReplacement;
function pipeInput(input: string, editor: vscode.TextEditor) {
return pipe(input, editor.selections.map(editor.document.getText)) as Thenable<
{ val?: string; err?: string }[]
>;
}
registerCommand(
Command.pipeFilter,
CommandFlags.ChangeSelections,
InputKind.Text,
getInputBoxOptionsWithoutReplacement,
async ({ editor }, state) => {
const outputs = await pipeInput(state.input, editor);
displayErrors(outputs);
const selections = [] as vscode.Selection[];
for (let i = 0; i < outputs.length; i++) {
const output = outputs[i];
if (!output.err && output.val !== "false") {
selections.push(editor.selections[i]);
}
}
editor.selections = selections;
},
);
registerCommand(
Command.pipeIgnore,
CommandFlags.None,
InputKind.Text,
getInputBoxOptionsWithoutReplacement,
async ({ editor }, state) => {
const outputs = await pipeInput(state.input, editor);
displayErrors(outputs);
},
);
registerCommand(
Command.pipeReplace,
CommandFlags.Edit,
InputKind.Text,
getInputBoxOptionsWithReplacement,
async ({ editor }, state, undoStops) => {
const outputs = await pipeInput(state.input, editor);
if (displayErrors(outputs)) {
return;
}
await editor.edit((builder) => {
const selections = editor.selections;
for (let i = 0; i < outputs.length; i++) {
builder.replace(selections[i], outputs[i].val!);
}
}, undoStops);
},
);
registerCommand(
Command.pipeAppend,
CommandFlags.Edit,
InputKind.Text,
getInputBoxOptionsWithoutReplacement,
async (editorState, state, undoStops) => {
const { editor } = editorState;
const outputs = await pipeInput(state.input, editor);
if (displayErrors(outputs)) {
return;
}
const selections = editor.selections,
selectionLengths = [] as number[],
selectionHelper = SelectionHelper.for(editorState, state);
await editor.edit((builder) => {
for (let i = 0; i < outputs.length; i++) {
const content = outputs[i].val!,
selection = selections[i];
builder.insert(selection.end, content);
selectionLengths.push(selectionHelper.selectionLength(selection));
}
}, undoStops);
// Restore selections that were extended automatically.
for (let i = 0; i < outputs.length; i++) {
selections[i] = selectionHelper.selectionFromLength(
selections[i].anchor,
selectionLengths[i],
);
}
editor.selections = selections;
},
);
registerCommand(
Command.pipePrepend,
CommandFlags.Edit,
InputKind.Text,
getInputBoxOptionsWithoutReplacement,
async ({ editor }, state, undoStops) => {
const outputs = await pipeInput(state.input, editor);
if (displayErrors(outputs)) {
return;
}
await editor.edit((builder) => {
for (let i = 0; i < outputs.length; i++) {
builder.insert(editor.selections[i].start, outputs[i].val!);
}
}, undoStops);
},
);

View File

@ -1,91 +0,0 @@
import * as vscode from "vscode";
import { Command, CommandFlags, registerCommand } from ".";
function rotateSelections(editor: vscode.TextEditor) {
const selections = editor.selections.slice(),
last = selections.length - 1,
firstSelection = selections[0];
for (let i = 0; i < last; i++) {
selections[i] = selections[i + 1];
}
selections[last] = firstSelection;
editor.selections = selections;
}
function rotateSelectionsBackwards(editor: vscode.TextEditor) {
const selections = editor.selections.slice(),
last = selections.length - 1,
lastSelection = selections[last];
for (let i = last; i > 0; i--) {
selections[i] = selections[i - 1];
}
selections[0] = lastSelection;
editor.selections = selections;
}
function rotateSelectionsContent(
editor: vscode.TextEditor,
undoStops: { undoStopBefore: boolean; undoStopAfter: boolean },
) {
return editor.edit((builder) => {
const { document: doc, selections } = editor;
for (let i = 0; i < selections.length - 1; i++) {
builder.replace(selections[i + 1], doc.getText(selections[i]));
}
builder.replace(selections[0], doc.getText(selections[selections.length - 1]));
}, undoStops);
}
function rotateSelectionsContentBackwards(
editor: vscode.TextEditor,
undoStops: { undoStopBefore: boolean; undoStopAfter: boolean },
) {
return editor.edit((builder) => {
const { document, selections } = editor;
for (let i = 0; i < selections.length - 1; i++) {
builder.replace(selections[i], document.getText(selections[i + 1]));
}
builder.replace(selections[selections.length - 1], document.getText(selections[0]));
}, undoStops);
}
registerCommand(Command.rotate, CommandFlags.ChangeSelections, ({ editor }) =>
rotateSelections(editor),
);
registerCommand(Command.rotateBackwards, CommandFlags.ChangeSelections, ({ editor }) =>
rotateSelectionsBackwards(editor),
);
registerCommand(Command.rotateContentOnly, CommandFlags.Edit, ({ editor }, _, undoStops) =>
rotateSelectionsContent(editor, undoStops).then(() => {}),
);
registerCommand(Command.rotateContentOnlyBackwards, CommandFlags.Edit, ({ editor }, _, undoStops) =>
rotateSelectionsContentBackwards(editor, undoStops).then(() => {}),
);
registerCommand(
Command.rotateContent,
CommandFlags.ChangeSelections | CommandFlags.Edit,
({ editor }, _, undoStops) =>
rotateSelectionsContent(editor, undoStops).then(() => rotateSelections(editor)),
);
registerCommand(
Command.rotateContentBackwards,
CommandFlags.ChangeSelections | CommandFlags.Edit,
({ editor }, _, undoStops) =>
rotateSelectionsContentBackwards(editor, undoStops).then(() =>
rotateSelectionsBackwards(editor),
),
);

View File

@ -1,371 +1,255 @@
// Search
// https://github.com/mawww/kakoune/blob/master/doc/pages/keys.asciidoc#searching
import * as vscode from "vscode";
import * as api from "../api";
import { Command, CommandFlags, InputKind, registerCommand } from ".";
import { Extension, SelectionBehavior } from "../state/extension";
import { WritableRegister } from "../registers";
import { SavedSelection } from "../utils/savedSelection";
import {
Backward,
Coord,
Direction,
DoNotExtend,
DocumentStart,
Extend,
ExtendBehavior,
Forward,
SelectionHelper,
} from "../utils/selectionHelper";
import { Argument, Input, RegisterOr, SetInput } from ".";
import { Context, Direction, EmptySelectionsError, Positions, prompt, Selections } from "../api";
import { Register } from "../state/registers";
import { manipulateSelectionsInteractively } from "../utils/misc";
import { escapeForRegExp } from "../utils/regexp";
import { CharSet, getCharSetFunction } from "../utils/charset";
function isMultilineRegExp(regex: string) {
const len = regex.length;
let negate = false;
/**
* Search for patterns and replace or add selections.
*/
declare module "./search";
for (let i = 0; i < len; i++) {
const ch = regex[i];
let lastSearchInput: RegExp | undefined;
if (negate) {
if (ch === "]") {
negate = false;
} else if (ch === "\\") {
if (regex[i + 1] === "S") {
return true;
}
/**
* Search.
*
* @keys `/` (normal)
*
* | Title | Identifier | Keybinding | Command |
* | ------------------------ | ----------------- | -------------- | ------------------------------------------------- |
* | Search (extend) | `extend` | `?` (normal) | `[".search", { shift: "extend" }]` |
* | Search backward | `backward` | `a-/` (normal) | `[".search", { direction: -1 }]` |
* | Search backward (extend) | `backward.extend` | `a-?` (normal) | `[".search", { direction: -1, shift: "extend" }]` |
*/
export function search(
_: Context,
register: RegisterOr<"slash", Register.Flags.CanWrite>,
repetitions: number,
i++; // Ignore next character
} else {
continue;
}
} else if (ch === "[" && regex[i + 1] === "^") {
negate = true;
i++;
} else if (ch === "\\") {
if (regex[i + 1] === "s" || regex[i + 1] === "n") {
return true;
}
add: Argument<boolean> = false,
direction: Direction = Direction.Forward,
interactive: Argument<boolean> = true,
shift: api.Shift = api.Shift.Jump,
i++; // Ignore next character
} else if (ch === "$" && i < len - 1) {
return true;
}
}
return false;
}
function execFromEnd(regex: RegExp, input: string) {
let match = regex.exec(input);
if (match === null || match[0].length === 0) {
return null;
}
for (;;) {
const newMatch = regex.exec(input);
if (newMatch === null) {
return match;
}
if (newMatch[0].length === 0) {
return null;
input: Input<string | RegExp>,
setInput: SetInput<RegExp>,
) {
return manipulateSelectionsInteractively(_, input, setInput, interactive, {
...prompt.regexpOpts("mug"),
value: lastSearchInput?.source,
}, (input, selections) => {
if (typeof input === "string") {
input = new RegExp(input, "mug");
}
match = newMatch;
}
}
lastSearchInput = input;
register.set([]);
function documentEnd(document: vscode.TextDocument) {
const lastLine = document.lineCount - 1;
return new vscode.Position(lastLine, document.lineAt(lastLine).text.length);
}
const newSelections = add ? selections.slice() : [],
regexpMatches = [] as RegExpMatchArray[];
function getSearchRange(
selection: vscode.Selection,
document: vscode.TextDocument,
direction: Direction,
isWrapped: boolean,
): vscode.Range {
if (isWrapped) {
return new vscode.Range(DocumentStart, documentEnd(document));
} else if (direction === Forward) {
return new vscode.Range(selection.end, documentEnd(document));
} else {
return new vscode.Range(DocumentStart, selection.start);
}
}
newSelections.push(...Selections.map.byIndex((_i, selection, document) => {
let newSelection = selection;
interface SearchState {
selectionBehavior: SelectionBehavior;
regex?: RegExp;
}
for (let j = 0; j < repetitions; j++) {
const searchResult = nextImpl(
input as RegExp, direction, newSelection, undefined, undefined, document,
/* allowWrapping= */ shift !== api.Shift.Extend, regexpMatches, regexpMatches.length);
function needleInHaystack(
direction: Direction,
allowWrapping: boolean,
): (
selection: vscode.Selection,
helper: SelectionHelper<SearchState>,
) => [Coord, Coord] | undefined {
return (selection, helper) => {
const document = helper.editor.document;
const regex = helper.state.regex!;
// Try finding in the normal search range first, then the wrapped search
// range.
for (const isWrapped of [false, true]) {
const searchRange = getSearchRange(selection, document, direction, isWrapped);
const text = document.getText(searchRange);
regex.lastIndex = 0;
const match = direction === Forward ? regex.exec(text) : execFromEnd(regex, text);
if (match) {
const startOffset = helper.offsetAt(searchRange.start) + match.index;
const firstCharacter = helper.coordAt(startOffset);
const lastCharacter = helper.coordAt(startOffset + match[0].length - 1);
return [firstCharacter, lastCharacter];
}
if (!allowWrapping) {
break;
}
}
return undefined;
};
}
function moveToNeedleInHaystack(
direction: Direction,
extend: ExtendBehavior,
): (
selection: vscode.Selection,
helper: SelectionHelper<SearchState>,
) => vscode.Selection | undefined {
const find = needleInHaystack(direction, !extend);
return (selection, helper) => {
const result = find(selection, helper);
if (result === undefined) {
return undefined;
}
const [start, end] = result;
if (extend) {
return helper.extend(selection, direction === Forward ? end : start);
} else {
// When not extending, the result selection should always face forward,
// regardless of old selection or search direction.
return helper.selectionBetween(start, end);
}
};
}
function registerSearchCommand(command: Command, direction: Direction, extend: ExtendBehavior) {
let initialSelections: readonly SavedSelection[],
register: WritableRegister,
helper: SelectionHelper<SearchState>;
const mapper = moveToNeedleInHaystack(direction, extend);
registerCommand(
command,
CommandFlags.ChangeSelections,
InputKind.Text,
() => ({
prompt: "Search RegExp",
setup(editorState) {
const { documentState, editor, extension } = editorState;
helper = SelectionHelper.for(editorState, {
selectionBehavior: extension.selectionBehavior,
});
initialSelections = editor.selections.map((selection) =>
documentState.saveSelection(selection),
);
const targetRegister = extension.currentRegister;
if (targetRegister === undefined || !targetRegister.canWrite()) {
register = extension.registers.slash;
} else {
register = targetRegister;
}
},
validateInput(input: string) {
if (input.length === 0) {
return "RegExp cannot be empty";
}
const editor = helper.editor;
const selections = initialSelections.map((selection) =>
selection.selection(editor.document),
);
let regex: RegExp;
const flags = (isMultilineRegExp(input) ? "m" : "") + (direction === Backward ? "g" : "");
try {
regex = new RegExp(input, flags);
} catch {
editor.selections = selections;
return "invalid ECMA RegExp";
}
helper.state.regex = regex;
const newSelections = [];
const len = selections.length;
const repetitions = helper.editorState.extension.currentCount || 1;
for (let i = 0; i < len; i++) {
let newSelection: vscode.Selection | undefined = selections[i];
for (let r = 0; r < repetitions; r++) {
newSelection = mapper(newSelection, helper);
if (!newSelection) {
break;
}
}
if (newSelection) {
newSelections.push(newSelection);
}
}
if (newSelections.length === 0) {
editor.selections = selections;
editor.revealRange(editor.selection);
return "No matches found";
} else {
editor.selections = newSelections;
editor.revealRange(editor.selection);
if (searchResult === undefined) {
return undefined;
}
},
onDidCancel({ editor, documentState }) {
editor.selections = initialSelections.map((selection) =>
selection.selection(editor.document),
);
documentState.forgetSelections(initialSelections);
},
}),
({ editor, documentState }) => {
register.set(editor, [helper.state.regex!.source]);
documentState.forgetSelections(initialSelections);
},
);
}
registerSearchCommand(Command.search, Forward, DoNotExtend);
registerSearchCommand(Command.searchBackwards, Backward, DoNotExtend);
registerSearchCommand(Command.searchExtend, Forward, Extend);
registerSearchCommand(Command.searchBackwardsExtend, Backward, Extend);
function setSearchSelection(source: string, editor: vscode.TextEditor, state: Extension) {
try {
new RegExp(source, "g");
} catch {
return Promise.reject(new Error(
"this should not happen -- please report this error along with the faulty RegExp"));
}
const register = state.currentRegister;
if (register === undefined || !register.canWrite()) {
state.registers.slash.set(editor, [source]);
} else {
register.set(editor, [source]);
}
return Promise.resolve();
}
function escapeRegExp(str: string) {
// See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#Escaping
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
registerCommand(Command.searchSelection, CommandFlags.ChangeSelections, ({ editor, extension }) => {
const text = escapeRegExp(editor.document.getText(editor.selection));
return setSearchSelection(text, editor, extension);
});
registerCommand(
Command.searchSelectionSmart,
CommandFlags.ChangeSelections,
({ editor, extension }) => {
const isWord = getCharSetFunction(CharSet.Word, editor.document),
firstLine = editor.document.lineAt(editor.selection.start).text,
firstLineStart = editor.selection.start.character;
let text = escapeRegExp(editor.document.getText(editor.selection));
if (firstLineStart === 0 || !isWord(firstLine.charCodeAt(firstLineStart - 1))) {
text = `\\b${text}`;
}
const lastLine = editor.document.lineAt(editor.selection.end).text,
lastLineEnd = editor.selection.end.character;
if (lastLineEnd >= lastLine.length || !isWord(lastLine.charCodeAt(lastLineEnd))) {
text = `${text}\\b`;
}
return setSearchSelection(text, editor, extension);
},
);
function nextNeedleInHaystack(
direction: Direction,
): (
selection: vscode.Selection,
helper: SelectionHelper<SearchState>,
) => vscode.Selection | undefined {
const find = needleInHaystack(direction, /* allowWrapping = */ true);
return (selection, helper) => {
const result = find(selection, helper);
if (result === undefined) {
return undefined;
}
const [start, end] = result;
// The result selection should always face forward,
// regardless of old selection or search direction.
return helper.selectionBetween(start, end);
};
}
function registerNextCommand(command: Command, direction: Direction, replace: boolean) {
const mapper = nextNeedleInHaystack(direction);
registerCommand(
command,
CommandFlags.ChangeSelections,
async (editorState, { currentRegister, selectionBehavior, repetitions }) => {
const { editor, extension } = editorState;
const regexStr = await (currentRegister ?? extension.registers.slash).get(editor);
if (regexStr === undefined || regexStr.length === 0) {
return;
newSelection = searchResult;
}
const regex = new RegExp(regexStr[0], "g"),
selections = editor.selections;
const searchState = { selectionBehavior, regex };
const helper = SelectionHelper.for(editorState, searchState);
let cur = selections[0];
if (shift === api.Shift.Jump) {
return newSelection;
}
const position = direction === Direction.Forward ? newSelection.end : newSelection.start;
return Selections.shift(selection, position, shift, _);
}, selections));
Selections.set(newSelections);
_.extension.registers.updateRegExpMatches(regexpMatches);
return register.set([input.source]).then(() => input as RegExp);
});
}
/**
* Search current selection.
*
* @keys `a-*` (normal)
*
* | Title | Identifier | Keybinding | Command |
* | -------------------------------- | ----------------- | ------------ | ---------------------------------------- |
* | Search current selection (smart) | `selection.smart` | `*` (normal) | `[".search.selection", { smart: true }]` |
*/
export function selection(
document: vscode.TextDocument,
selections: readonly vscode.Selection[],
register: RegisterOr<"slash", Register.Flags.CanWrite>,
smart: Argument<boolean> = false,
) {
const texts = [] as string[],
isWord = smart ? getCharSetFunction(CharSet.Word, document) : undefined;
for (const selection of selections) {
let text = escapeForRegExp(document.getText(selection));
if (smart) {
const firstLine = document.lineAt(selection.start).text,
firstLineStart = selection.start.character;
if (firstLineStart === 0 || !isWord!(firstLine.charCodeAt(firstLineStart - 1))) {
text = `\\b${text}`;
}
const lastLine = selection.isSingleLine ? firstLine : document.lineAt(selection.end).text,
lastLineEnd = selection.end.character;
if (lastLineEnd >= lastLine.length || !isWord!(lastLine.charCodeAt(lastLineEnd))) {
text = `${text}\\b`;
}
}
texts.push(text);
}
register.set(texts);
}
/**
* Select next match.
*
* @keys `n` (normal)
*
* | Title | Identifier | Keybinding | Command |
* | --------------------- | -------------- | ---------------- | ------------------------------------------------ |
* | Add next match | `next.add` | `s-n` (normal) | `[".search.next", { add: true }]` |
* | Select previous match | `previous` | `a-n` (normal) | `[".search.next", { direction: -1 }]` |
* | Add previous match | `previous.add` | `s-a-n` (normal) | `[".search.next", { direction: -1, add: true }]` |
*/
export async function next(
_: Context,
document: vscode.TextDocument,
register: RegisterOr<"slash", Register.Flags.CanRead>,
repetitions: number,
add: Argument<boolean> = false,
direction: Direction = Direction.Forward,
) {
const reStrs = await register.get();
if (reStrs === undefined || reStrs.length === 0) {
return;
}
const re = new RegExp(reStrs[0], "mu"),
allRegexpMatches = [] as RegExpMatchArray[];
if (!add) {
Selections.update.byIndex((_i, selection) => {
for (let j = 0; j < repetitions; j++) {
const next = nextImpl(
re, direction, selection, undefined, undefined, document, /* allowWrapping= */ true,
allRegexpMatches, allRegexpMatches.length);
for (let i = repetitions; i > 0; i--) {
const next = mapper(cur, helper);
if (next === undefined) {
throw new Error("no matches found");
return undefined;
}
cur = next;
if (replace) {
selections[0] = cur;
} else {
selections.unshift(cur);
}
selection = next;
}
editor.selections = selections;
},
);
return selection;
});
_.extension.registers.updateRegExpMatches(allRegexpMatches);
return;
}
const selections = _.selections.slice(),
allSelections = selections.slice();
for (let i = 0; i < repetitions; i++) {
const newSelections = [] as vscode.Selection[],
regexpMatches = [] as RegExpMatchArray[];
for (let j = 0; j < selections.length; j++) {
const selection = selections[j],
next = nextImpl(
re, direction, selection, undefined, undefined, document, /* allowWrapping= */ true,
regexpMatches, regexpMatches.length);
if (next !== undefined) {
selections[j] = next;
newSelections.push(next);
}
}
if (newSelections.length === 0) {
const target = direction === Direction.Backward ? "previous" : "next",
times = repetitions === 1 ? "time" : "times";
throw new EmptySelectionsError(
`no selection could advance to ${target} match ${repetitions} ${times}`,
);
}
allSelections.unshift(...newSelections);
allRegexpMatches.unshift(...regexpMatches);
}
Selections.set(allSelections);
_.extension.registers.updateRegExpMatches(allRegexpMatches);
}
registerNextCommand(Command.searchNext, Forward, true);
registerNextCommand(Command.searchNextAdd, Forward, false);
registerNextCommand(Command.searchPrevious, Backward, true);
registerNextCommand(Command.searchPreviousAdd, Backward, false);
function nextImpl(
re: RegExp,
direction: Direction,
selection: vscode.Selection,
searchStart: vscode.Position | undefined,
searchEnd: vscode.Position | undefined,
document: vscode.TextDocument,
allowWrapping: boolean,
matches: RegExpMatchArray[] | undefined,
matchesIndex: number,
): vscode.Selection | undefined {
searchStart ??= direction === Direction.Backward ? selection.start : selection.end;
const searchResult = api.search(direction, re, searchStart, searchEnd);
if (searchResult === undefined) {
if (allowWrapping) {
if (direction === Direction.Backward) {
searchStart = Positions.last(document);
searchEnd = Positions.zero;
} else {
searchStart = Positions.zero;
searchEnd = Positions.last(document);
}
return nextImpl(
re, direction, selection, searchStart, searchEnd, document, false, matches, matchesIndex);
}
return;
}
if (matches !== undefined) {
matches[matchesIndex] = searchResult[1];
}
return Selections.fromLength(
searchResult[0], searchResult[1][0].length, /* isReversed= */ false, document);
}

462
src/commands/seek.ts Normal file
View File

@ -0,0 +1,462 @@
import * as vscode from "vscode";
import { Argument, InputOr } from ".";
import { ArgumentError, assert, Context, Direction, keypress, Lines, moveTo, moveWhile, Pair, pair, Positions, prompt, Selections, Shift, surroundedBy, todo } from "../api";
import { Range } from "../api/search/range";
import { wordBoundary } from "../api/search/word";
import { SelectionBehavior } from "../state/modes";
import { CharSet } from "../utils/charset";
import { execRange } from "../utils/regexp";
/**
* Update selections based on the text surrounding them.
*/
declare module "./seek";
/**
* Select to character (excluded).
*
* @keys `t` (normal)
*
* #### Variants
*
* | Title | Identifier | Keybinding | Command |
* | ---------------------------------------- | -------------------------- | ---------------- | -------------------------------------------------------------- |
* | Extend to character (excluded) | `extend` | `s-t` (normal) | `[".seek", { shift: "extend" }]` |
* | Select to character (excluded, backward) | `backward` | `a-t` (normal) | `[".seek", { direction: -1 }]` |
* | Extend to character (excluded, backward) | `extend.backward` | `s-a-t` (normal) | `[".seek", { shift: "extend", direction: -1 }]` |
* | Select to character (included) | `included` | `f` (normal) | `[".seek", { include: true }]` |
* | Extend to character (included) | `included.extend` | `s-f` (normal) | `[".seek", { include: true, shift: "extend" }]` |
* | Select to character (included, backward) | `included.backward` | `a-f` (normal) | `[".seek", { include: true, direction: -1 }]` |
* | Extend to character (included, backward) | `included.extend.backward` | `s-a-f` (normal) | `[".seek", { include: true, shift: "extend", direction: -1 }]` |
*/
export async function seek(
_: Context,
inputOr: InputOr<string>,
repetitions: number,
direction = Direction.Forward,
shift = Shift.Select,
include: Argument<boolean> = false,
) {
const input = await inputOr(() => keypress(_));
Selections.update.byIndex((_, selection, document) => {
let position: vscode.Position | undefined = Selections.seekFrom(selection, -direction);
for (let i = 0; i < repetitions; i++) {
position = Positions.offset(position, direction, document);
if (position === undefined) {
return undefined;
}
position = moveTo.excluded(direction, input, position, document);
if (position === undefined) {
return undefined;
}
}
if (include && !(shift === Shift.Extend && direction === Direction.Backward && position.isAfter(selection.anchor))) {
position = Positions.offset(position, input.length * direction);
if (position === undefined) {
return undefined;
}
}
return Selections.shift(selection, position, shift);
});
}
const defaultEnclosingPatterns = [
"\\[", "\\]",
"\\(", "\\)",
"\\{", "\\}",
"/\\*", "\\*/",
"\\bbegin\\b", "\\bend\\b",
];
/**
* Select to next enclosing character.
*
* @keys `m` (normal)
*
* #### Variants
*
* | Title | Identifier | Keybinding | Command |
* | -------------------------------------- | --------------------------- | ---------------- | --------------------------------------------------------- |
* | Extend to next enclosing character | `enclosing.extend` | `s-m` (normal) | `[".seek.enclosing", { shift: "extend" }]` |
* | Select to previous enclosing character | `enclosing.backward` | `a-m` (normal) | `[".seek.enclosing", { direction: -1 }]` |
* | Extend to previous enclosing character | `enclosing.extend.backward` | `s-a-m` (normal) | `[".seek.enclosing", { shift: "extend", direction: -1 }]` |
*/
export function enclosing(
_: Context,
direction = Direction.Forward,
shift = Shift.Select,
open: Argument<boolean> = true,
pairs: Argument<readonly string[]> = defaultEnclosingPatterns,
) {
ArgumentError.validate(
"pairs",
(pairs.length & 1) === 0,
"an even number of pairs must be given",
);
const selectionBehavior = _.selectionBehavior,
compiledPairs = [] as Pair[];
for (let i = 0; i < pairs.length; i += 2) {
compiledPairs.push(pair(new RegExp(pairs[i], "mu"), new RegExp(pairs[i + 1], "mu")));
}
// This command intentionally ignores repetitions to be consistent with
// Kakoune.
// It only finds one next enclosing character and drags only once to its
// matching counterpart. Repetitions > 1 does exactly the same with rep=1,
// even though executing the command again will jump back and forth.
Selections.update.byIndex((_, selection, document) => {
// First, find an enclosing char (which may be the current character).
let currentCharacter = selection.active;
if (selectionBehavior === SelectionBehavior.Caret) {
if (direction === Direction.Backward && selection.isReversed) {
// When moving backwards, the first character to consider is the
// character to the left, not the right. However, we hackily special
// case `|[foo]>` (> is anchor, | is active) to jump to the end in the
// current group.
currentCharacter = Positions.previous(currentCharacter, document) ?? currentCharacter;
} else if (direction === Direction.Forward && !selection.isReversed && !selection.isEmpty) {
// Similarly, we special case `<[foo]|` to jump back in the current
// group.
currentCharacter = Positions.previous(currentCharacter, document) ?? currentCharacter;
}
}
if (selectionBehavior === SelectionBehavior.Caret && direction === Direction.Backward) {
// When moving backwards, the first character to consider is the
// character to the left, not the right.
currentCharacter = Positions.previous(currentCharacter, document) ?? currentCharacter;
}
const enclosedRange = surroundedBy(compiledPairs, direction, currentCharacter, open, document);
if (enclosedRange === undefined) {
return undefined;
}
if (shift === Shift.Extend) {
return new vscode.Selection(selection.anchor, enclosedRange.active);
}
return enclosedRange;
});
}
/**
* Select to next word start.
*
* Select the word and following whitespaces on the right of the end of each selection.
*
* @keys `w` (normal)
*
* #### Variants
*
* | Title | Identifier | Keybinding | Command |
* | -------------------------------------------- | ------------------------- | ---------------- | -------------------------------------------------------------------------------- |
* | Extend to next word start | `word.extend` | `s-w` (normal) | `[".seek.word", { shift: "extend" }]` |
* | Select to previous word start | `word.backward` | `b` (normal) | `[".seek.word", { direction: -1 }]` |
* | Extend to previous word start | `word.extend.backward` | `s-b` (normal) | `[".seek.word", { shift: "extend", direction: -1 }]` |
* | Select to next non-whitespace word start | `word.ws` | `a-w` (normal) | `[".seek.word", { ws: true }]` |
* | Extend to next non-whitespace word start | `word.ws.extend` | `s-a-w` (normal) | `[".seek.word", { ws: true, shift: "extend" }]` |
* | Select to previous non-whitespace word start | `word.ws.backward` | `a-b` (normal) | `[".seek.word", { ws: true, direction: -1 }]` |
* | Extend to previous non-whitespace word start | `word.ws.extend.backward` | `s-a-b` (normal) | `[".seek.word", { ws: true, shift: "extend", direction: -1 }]` |
* | Select to next word end | `wordEnd` | `e` (normal) | `[".seek.word", { stopAtEnd: true }]` |
* | Extend to next word end | `wordEnd.extend` | `s-e` (normal) | `[".seek.word", { stopAtEnd: true , shift: "extend" }]` |
* | Select to next non-whitespace word end | `wordEnd.ws` | `a-e` (normal) | `[".seek.word", { stopAtEnd: true , ws: true }]` |
* | Extend to next non-whitespace word end | `wordEnd.ws.extend` | `s-a-e` (normal) | `[".seek.word", { stopAtEnd: true , ws: true, shift: "extend" }]` |
*/
export function word(
_: Context,
repetitions: number,
stopAtEnd: Argument<boolean> = false,
ws: Argument<boolean> = false,
direction = Direction.Forward,
shift = Shift.Select,
) {
const charset = ws ? CharSet.NonBlank : CharSet.Word;
Selections.update.withFallback.byIndex((_i, selection) => {
const anchor = Selections.seekFrom(selection, direction, selection.anchor, _);
let active = Selections.seekFrom(selection, direction, selection.active, _);
for (let i = 0; i < repetitions; i++) {
const mapped = wordBoundary(direction, active, stopAtEnd, charset, _);
if (mapped === undefined) {
if (direction === Direction.Backward && active.line > 0) {
// This is a special case in Kakoune and we try to mimic it
// here.
// Instead of overflowing, put anchor at document start and
// active always on the first character on the second line.
const end = _.selectionBehavior === SelectionBehavior.Caret
? Positions.lineStart(1)
: (Lines.isEmpty(1) ? Positions.lineStart(2) : Positions.at(1, 1));
return new vscode.Selection(Positions.lineStart(0), end);
}
if (shift === Shift.Extend) {
return [new vscode.Selection(anchor, selection.active)];
}
return [selection];
}
selection = mapped;
active = selection.active;
}
if (shift === Shift.Extend) {
return new vscode.Selection(anchor, selection.active);
}
return selection;
});
}
let lastObjectInput: string | undefined;
/**
* Select object.
*
* @param input The pattern of object to select; see
* [object patterns](#object-patterns) below for more information.
* @param inner If `true`, only the "inner" part of the object will be selected.
* The definition of the "inner" part depends on the object.
* @param where What end of the object should be sought. If `undefined`, the
* object will be selected from start to end regardless of the `shift`.
*
* #### Object patterns
* - Pairs: `<regexp>(?#inner)<regexp>`.
* - Character sets: `[<characters>]+`.
* - Can be preceded by `(?<before>[<characters>]+)` and followed by
* `(?<after>[<character>]+)` for whole objects.
* - Matches that may only span a single line: `(?#singleline)<regexp>`.
* - Predefined: `(?#predefined=<argument | paragraph | sentence>)`.
*
* #### Variants
*
* | Title | Identifier | Keybinding | Command |
* | ---------------------------- | ------------------------------ | ------------------------------ | ---------------------------------------------------------------------------------------------- |
* | Select whole object | `askObject` | `a-a` (normal), `a-a` (insert) | `[".openMenu", { input: "object" }]` |
* | Select inner object | `askObject.inner` | `a-i` (normal), `a-i` (insert) | `[".openMenu", { input: "object", pass: [{ inner: true }] }]` |
* | Select to whole object start | `askObject.start` | `[` (normal) | `[".openMenu", { input: "object", pass: [{ where: "start" }] }]` |
* | Extend to whole object start | `askObject.start` | `{` (normal) | `[".openMenu", { input: "object", pass: [{ where: "start", shift: "extend" }] }]` |
* | Select to inner object start | `askObject.inner.start` | `a-[` (normal) | `[".openMenu", { input: "object", pass: [{ inner: true, where: "start" }] }]` |
* | Extend to inner object start | `askObject.inner.start.extend` | `a-{` (normal) | `[".openMenu", { input: "object", pass: [{ inner: true, where: "start", shift: "extend" }] }]` |
* | Select to whole object end | `askObject.end` | `]` (normal) | `[".openMenu", { input: "object", pass: [{ where: "end" }] }]` |
* | Extend to whole object end | `askObject.end` | `}` (normal) | `[".openMenu", { input: "object", pass: [{ where: "end" , shift: "extend" }] }]` |
* | Select to inner object end | `askObject.inner.end` | `a-]` (normal) | `[".openMenu", { input: "object", pass: [{ inner: true, where: "end" }] }]` |
* | Extend to inner object end | `askObject.inner.end.extend` | `a-}` (normal) | `[".openMenu", { input: "object", pass: [{ inner: true, where: "end" , shift: "extend" }] }]` |
*/
export async function object(
_: Context,
inputOr: InputOr<string>,
inner: Argument<boolean> = false,
where?: Argument<"start" | "end">,
shift = Shift.Select,
) {
const input = await inputOr(() => prompt({
prompt: "Object description",
value: lastObjectInput,
}));
let match: RegExpExecArray | null;
if (match = /^(.+)\(\?#inner\)(.+)$/s.exec(input)) {
const openRe = new RegExp(preprocessRegExp(match[1]), "u"),
closeRe = new RegExp(preprocessRegExp(match[2]), "u"),
p = pair(openRe, closeRe);
if (where === "start") {
return Selections.update.byIndex((_i, selection) => {
const startResult = p.searchOpening(Selections.activeStart(selection, _));
if (startResult === undefined) {
return undefined;
}
const start = inner
? Positions.offset(startResult[0], startResult[1][0].length, _.document) ?? startResult[0]
: startResult[0];
return Selections.shift(selection, start, shift, _);
});
}
if (where === "end") {
return Selections.update.byIndex((_i, selection) => {
const endResult = p.searchClosing(Selections.activeEnd(selection, _));
if (endResult === undefined) {
return undefined;
}
const end = inner
? endResult[0]
: Positions.offset(endResult[0], endResult[1][0].length, _.document) ?? endResult[0];
return Selections.shift(selection, end, shift, _);
});
}
return shiftWhere(
_,
(selection, _) => surroundedBy(
[p], Direction.Backward, Selections.activeEnd(selection, _), !inner, _.document),
shift,
where,
);
}
if (match =
/^(?:\(\?<before>(\[.+?\])\+\))?(\[.+\])\+(?:\(\?<after>(\[.+?\])\+\))?$/.exec(input)) {
const re = new RegExp(match[2], "u"),
beforeRe = inner || match[1] === undefined ? undefined : new RegExp(match[1], "u"),
afterRe = inner || match[3] === undefined ? undefined : new RegExp(match[3], "u");
return shiftWhere(
_,
(selection, _) => {
let start = moveWhile.backward((c) => re.test(c), selection.active, _.document),
end = moveWhile.forward((c) => re.test(c), selection.active, _.document);
if (beforeRe !== undefined) {
start = moveWhile.backward((c) => beforeRe.test(c), start, _.document);
}
if (afterRe !== undefined) {
end = moveWhile.forward((c) => afterRe.test(c), end, _.document);
}
return new vscode.Selection(start, end);
},
shift,
where,
);
}
if (match = /^\(\?#singleline\)(.+)$/.exec(input)) {
const re = new RegExp(preprocessRegExp(match[1]), "u");
return shiftWhere(
_,
(selection, _) => {
const line = Selections.activeLine(selection),
lineText = _.document.lineAt(line).text,
matches = execRange(lineText, re);
// Find match at text position.
const character = Selections.activeCharacter(selection, _.document);
for (const m of matches) {
let [start, end] = m;
if (start <= character && character <= end) {
if (inner && m[2].groups !== undefined) {
const match = m[2];
if ("before" in match.groups!) {
start += match.groups.before.length;
}
if ("after" in match.groups!) {
end -= match.groups.after.length;
}
}
return new vscode.Selection(
new vscode.Position(line, start),
new vscode.Position(line, end),
);
}
}
return undefined;
},
shift,
where,
);
}
if (match = /^\(\?#predefined=(argument|indent|paragraph|sentence)\)$/.exec(input)) {
let f: Range.Seek;
switch (match[1]) {
case "argument":
case "indent":
case "paragraph":
case "sentence":
f = Range[match[1]];
break;
default:
assert(false);
}
if (where === "start") {
Selections.update.byIndex((_i, selection, document) =>
Selections.shift(
selection,
f.start(Selections.activePosition(selection, _.document), inner, document),
shift,
_,
),
);
} else if (where === "end") {
Selections.update.byIndex((_i, selection, document) =>
Selections.shift(
selection,
f.end(selection.active, inner, document),
shift,
_,
),
);
} else {
Selections.update.byIndex((_, selection, document) => f(selection.active, inner, document));
}
return;
}
throw new Error("unknown object " + JSON.stringify(input));
}
function preprocessRegExp(re: string) {
return re.replace(/\(\?#noescape\)/g, "(?<=(?<!\\\\)(?:\\\\{2})*)");
}
function shiftWhere(
context: Context,
f: (selection: vscode.Selection, context: Context) => vscode.Selection | undefined,
shift: Shift,
where: "start" | "end" | undefined,
) {
Selections.update.byIndex((_, selection) => {
const result = f(selection, context);
if (result === undefined) {
return undefined;
}
if (where === undefined) {
return result;
}
return Selections.shift(selection, result[where], shift, context);
});
}

File diff suppressed because it is too large Load Diff

View File

@ -1,897 +0,0 @@
// Objects
// https://github.com/mawww/kakoune/blob/master/doc/pages/keys.asciidoc#object-selection
import * as vscode from "vscode";
import { Command, CommandFlags, CommandState, registerCommand } from ".";
import {
Backward,
Coord,
CoordMapper,
Direction,
DoNotExtend,
DocumentStart,
Extend,
Forward,
RemoveSelection,
SelectionHelper,
SelectionMapper,
moveActiveCoord,
seekToRange,
} from "../utils/selectionHelper";
import { CharSet, getCharSetFunction } from "../utils/charset";
import { findMatching, skipWhile, skipWhileX } from "./select";
import { SelectionBehavior } from "../state/extension";
// Selecting is a bit harder than it sounds like:
// 1. Dealing with multiple lines, whether forwards or backwards, is a bit of a
// pain.
// 2. Dealing with strings is a bit of a pain
// 3. Dealing with inner objects is a bit of a pain
const [
LPAREN,
RPAREN,
LSQBRACKET,
RSQBRACKET,
LCRBRACKET,
RCRBRACKET,
LCHEVRON,
RCHEVRON,
LF,
SPACE,
QUOTE_DBL,
QUOTE_SGL,
BACKTICK,
BACKSLASH,
COMMA,
] = Array.from("()[]{}<>\n \"'`\\,", (ch) => ch.charCodeAt(0));
function objectActions(
toStart: CoordMapper,
toEnd: CoordMapper,
toStartInner: CoordMapper,
toEndInner: CoordMapper,
scanFromStart: boolean = false,
) {
const selectObject: SelectionMapper = (selection, helper, i) => {
const active = helper.activeCoord(selection);
const start = toStart(active, helper, i);
if ("remove" in start) {
return RemoveSelection;
}
const end = toEnd(scanFromStart ? start : active, helper, i);
if ("remove" in end) {
return RemoveSelection;
}
return helper.selectionBetween(start, end);
};
const selectObjectInner: SelectionMapper = (selection, helper, i) => {
const active = helper.activeCoord(selection);
const start = toStartInner(active, helper, i);
if ("remove" in start) {
return RemoveSelection;
}
const end = toEndInner(scanFromStart ? start : active, helper, i);
if ("remove" in end) {
return RemoveSelection;
}
return helper.selectionBetween(start, end);
};
return {
select: {
outer: selectObject,
inner: selectObjectInner,
},
selectToStart: {
outer: {
doNotExtend: moveActiveCoord(toStart, DoNotExtend),
extend: moveActiveCoord(toStart, Extend),
},
inner: {
doNotExtend: moveActiveCoord(toStartInner, DoNotExtend),
extend: moveActiveCoord(toStartInner, Extend),
},
},
selectToEnd: {
outer: {
doNotExtend: moveActiveCoord(toEnd, DoNotExtend),
extend: moveActiveCoord(toEnd, Extend),
},
inner: {
doNotExtend: moveActiveCoord(toEndInner, DoNotExtend),
extend: moveActiveCoord(toEndInner, Extend),
},
},
};
}
function objectWithinPair(startCharCode: number, endCharCode: number) {
const toStart: CoordMapper = (active, helper) =>
findMatching(Backward, active, startCharCode, endCharCode, helper.editor.document)
?? RemoveSelection;
const toEnd: CoordMapper = (active, helper) =>
findMatching(Forward, active, endCharCode, startCharCode, helper.editor.document)
?? RemoveSelection;
const toStartInner: CoordMapper = (active, helper, i) => {
const pos = toStart(active, helper, i);
if ("remove" in pos) {
return pos;
}
return helper.nextPos(pos);
};
const toEndInner: CoordMapper = (active, helper, i) => {
const pos = toEnd(active, helper, i);
if ("remove" in pos) {
return pos;
}
return helper.prevPos(pos);
};
const actions = objectActions(toStart, toEnd, toStartInner, toEndInner);
// Special cases for selectObject and selectObjectInner when active is at the
// start / end of an object, so that it always select a whole object within
// a matching pair. e.g. (12345) when active at first character should select
// the whole thing instead of error.
const defaultSelect = actions.select.outer;
const defaultSelectInner = actions.select.inner;
actions.select.outer = (selection, helper, i) => {
const active = helper.activeCoord(selection);
const currentCharCode = helper.editor.document
.lineAt(active.line)
.text.charCodeAt(active.character);
if (currentCharCode === startCharCode) {
const end = toEnd(active, helper, i);
if ("remove" in end) {
return RemoveSelection;
}
return helper.selectionBetween(active, end);
} else if (currentCharCode === endCharCode) {
const start = toStart(active, helper, i);
if ("remove" in start) {
return RemoveSelection;
}
return helper.selectionBetween(start, active);
} else {
return defaultSelect(selection, helper, i);
}
};
actions.select.inner = (selection, helper, i) => {
const active = helper.activeCoord(selection);
const currentCharCode = helper.editor.document
.lineAt(active.line)
.text.charCodeAt(active.character);
if (currentCharCode === startCharCode) {
const end = toEndInner(active, helper, i);
if ("remove" in end) {
return RemoveSelection;
}
return helper.selectionBetween(helper.nextPos(active), end);
} else if (currentCharCode === endCharCode) {
const start = toStartInner(active, helper, i);
if ("remove" in start) {
return RemoveSelection;
}
return helper.selectionBetween(start, helper.prevPos(active));
} else {
return defaultSelectInner(selection, helper, i);
}
};
return actions;
}
type CharCodePredicate = (charCode: number) => boolean;
function objectWithCharSet(charSet: CharSet | CharCodePredicate) {
function toEdge(direction: Direction, includeTrailingWhitespace: boolean): CoordMapper {
return (active, helper) => {
const { document } = helper.editor;
const isInSet
= typeof charSet === "function" ? charSet : getCharSetFunction(charSet, document);
let col = active.character;
const text = document.lineAt(active.line).text;
if (col >= text.length) {
// A charset object cannot contain line break, therefore the cursor
// active is not within any such object.
return RemoveSelection;
}
while (isInSet(text.charCodeAt(col))) {
col += direction;
if (col < 0 || col >= text.length) {
return active.with(undefined, col - direction);
}
}
if (col === active.character) {
// The cursor active is on a character outside charSet.
return RemoveSelection;
}
if (includeTrailingWhitespace) {
const isBlank = getCharSetFunction(CharSet.Blank, document);
while (isBlank(text.charCodeAt(col))) {
col += direction;
if (col < 0 || col >= text.length) {
return active.with(undefined, col - direction);
}
}
}
return active.with(undefined, col - direction);
};
}
const toStart = toEdge(Backward, false);
const toStartInner = toStart;
const toEnd = toEdge(Forward, true);
const toEndInner = toEdge(Forward, false);
return objectActions(toStart, toEnd, toStartInner, toEndInner);
}
function sentenceObject() {
// I bet that's the first time you see a Greek question mark used as an actual
// Greek question mark, rather than as a "prank" semicolon.
const punctCharCodes = new Uint32Array(Array.from(".!?¡§¶¿;՞。", (ch) => ch.charCodeAt(0)));
function toBeforeBlank(allowSkipToPrevious: boolean) {
return (oldActive: Coord, helper: SelectionHelper<CommandState>) => {
const document = helper.editor.document;
const isBlank = getCharSetFunction(CharSet.Blank, document);
const origin = oldActive; // TODO: Adjust for caret mode.
let jumpedOverBlankLine = false;
let skipCurrent = allowSkipToPrevious;
let hadLf = true;
const beforeBlank = skipWhileX(
Backward,
origin,
(charCode) => {
if (charCode === LF) {
if (hadLf) {
jumpedOverBlankLine = true;
return allowSkipToPrevious;
}
hadLf = true;
skipCurrent = false;
return true;
} else {
hadLf = false;
if (skipCurrent) {
skipCurrent = false;
return true;
}
return isBlank(charCode);
}
},
document,
);
if (beforeBlank === undefined) {
return origin;
}
const beforeBlankChar = document
.lineAt(beforeBlank.line)
.text.charCodeAt(beforeBlank.character);
const hitPunctChar = punctCharCodes.includes(beforeBlankChar);
if (jumpedOverBlankLine && (!allowSkipToPrevious || !hitPunctChar)) {
// We jumped over blank lines but didn't hit a punct char. Don't accept.
return origin;
}
// let result = beforeBlank.isEqual(DocumentStart)
// ? beforeBlank
// : helper.prevPos(beforeBlank)
if (!hitPunctChar) {
return beforeBlank;
}
if (allowSkipToPrevious) {
return { prevSentenceEnd: beforeBlank };
}
if (origin.line === beforeBlank.line) {
return beforeBlank;
}
// Example below: we started from '|' and found the '.'.
// foo.
// | bar
// In this case, technically we started from the second sentence
// and reached the first sentence. This is not permitted when when
// allowSkipToPrevious is false, so let's go back.
return origin;
};
}
const toSentenceStart = (origin: Coord, helper: SelectionHelper<CommandState>): Coord => {
const document = helper.editor.document;
const isBlank = getCharSetFunction(CharSet.Blank, document);
let originLineText = document.lineAt(origin.line).text;
if (originLineText.length === 0 && origin.line + 1 >= document.lineCount) {
if (origin.line === 0) {
// There is only one line and that line is empty. What a life.
return DocumentStart;
}
// Special case: If at the last line, search from the previous line.
originLineText = document.lineAt(origin.line - 1).text;
origin = origin.with(origin.line - 1, originLineText.length);
}
if (originLineText.length === 0) {
// This line is empty. Just go to the first non-blank char on next line.
const nextLineText = document.lineAt(origin.line + 1).text;
let col = 0;
while (col < nextLineText.length && isBlank(nextLineText.charCodeAt(col))) {
col++;
}
return new Coord(origin.line + 1, col);
}
let first = true;
let hadLf = false;
const afterSkip = skipWhileX(
Backward,
origin,
(charCode) => {
if (charCode === LF) {
first = false;
if (hadLf) {
return false;
}
hadLf = true;
} else {
hadLf = false;
if (first) {
// Don't need to check if first character encountered is punct --
// that may be the current sentence end.
first = false;
return true;
}
if (punctCharCodes.indexOf(charCode) >= 0) {
return false;
}
}
return true;
},
document,
);
// If we hit two LFs or document start, the current sentence starts at the
// first non-blank character after that.
if (hadLf || !afterSkip) {
return skipWhileX(Forward, afterSkip ?? DocumentStart, isBlank, document) ?? DocumentStart;
}
// If we hit a punct char, then the current sentence starts on the first
// non-blank character on the same line, or the line break.
let col = afterSkip.character + 1;
const text = document.lineAt(afterSkip.line).text;
while (col < text.length && isBlank(text.charCodeAt(col))) {
col++;
}
return afterSkip.with(undefined, col);
};
function toEnd(inner: boolean): CoordMapper {
return (origin, helper) => {
const document = helper.editor.document;
if (document.lineAt(origin.line).text.length === 0) {
// We're on an empty line which does not belong to last sentence or this
// sentence. If next line is also empty, we should just stay here.
// However, start scanning from the next line if it is not empty.
if (
origin.line + 1 >= document.lineCount
|| document.lineAt(origin.line + 1).text.length === 0
) {
return origin;
} else {
origin = new Coord(origin.line + 1, 0);
}
}
const isBlank = getCharSetFunction(CharSet.Blank, document);
let hadLf = false;
const innerEnd = skipWhileX(
Forward,
origin,
(charCode) => {
if (charCode === LF) {
if (hadLf) {
return false;
}
hadLf = true;
} else {
hadLf = false;
if (punctCharCodes.indexOf(charCode) >= 0) {
return false;
}
}
return true;
},
document,
);
if (!innerEnd) {
return helper.lastCoord();
}
// If a sentence ends with two LFs in a row, then the first LF is part of
// the inner & outer sentence while the second LF should be excluded.
if (hadLf) {
return helper.prevPos(innerEnd);
}
if (inner) {
return innerEnd;
}
// If a sentence ends with punct char, then any blank characters after it
// but BEFORE any line breaks belongs to the outer sentence.
let col = innerEnd.character + 1;
const text = document.lineAt(innerEnd.line).text;
while (col < text.length && isBlank(text.charCodeAt(col))) {
col++;
}
return innerEnd.with(undefined, col - 1);
};
}
const toBeforeBlankCurrent = toBeforeBlank(false);
const toCurrentStart: CoordMapper = (oldActive, helper, i) => {
let beforeBlank = toBeforeBlankCurrent(oldActive, helper);
if ("prevSentenceEnd" in beforeBlank) {
beforeBlank = oldActive;
}
return toSentenceStart(beforeBlank, helper);
};
// It is imposssible to determine if active is at leading or trailing or
// in-sentence blank characters by just looking ahead. Therefore, we search
// from the sentence start, which may be slightly less efficient but
// always accurate.
const scanFromStart = true;
const actions = objectActions(
toCurrentStart,
toEnd(false),
toCurrentStart,
toEnd(true),
scanFromStart,
);
// Special cases to allow jumping to the previous sentence when active is at
// current sentence start / leading blank chars.
const toBeforeBlankOrPrev = toBeforeBlank(true);
actions.selectToStart.inner = actions.selectToStart.outer = {
extend: moveActiveCoord((oldActive, helper, i) => {
let beforeBlank = toBeforeBlankOrPrev(oldActive, helper);
if ("prevSentenceEnd" in beforeBlank) {
beforeBlank = beforeBlank.prevSentenceEnd;
}
return toSentenceStart(beforeBlank, helper);
}, Extend),
doNotExtend: (selection: vscode.Selection, helper: SelectionHelper<CommandState>) => {
const oldActive = helper.activeCoord(selection);
const beforeBlank = toBeforeBlankOrPrev(oldActive, helper);
if ("prevSentenceEnd" in beforeBlank) {
const newAnchor = beforeBlank.prevSentenceEnd;
console.log("hit prev end", helper._visualizeCoord(newAnchor));
// Special case: re-anchor when skipping to last sentence end.
return helper.selectionBetween(newAnchor, toSentenceStart(newAnchor, helper));
} else {
const newActive = toSentenceStart(beforeBlank, helper);
if (helper.selectionBehavior === SelectionBehavior.Caret) {
// TODO: Optimize to avoid coordAt / offsetAt.
const activePos = selection.active.isBeforeOrEqual(newActive)
? helper.coordAt(helper.offsetAt(newActive) + 1)
: newActive;
return new vscode.Selection(selection.active, activePos);
}
return helper.selectionBetween(oldActive, newActive);
}
},
};
return actions;
}
function paragraphObject() {
const lookBack: CoordMapper = (active, helper) => {
const { line } = active;
if (
line > 0
&& active.character === 0
&& helper.editor.document.lineAt(line - 1).text.length === 0
) {
return new Coord(line - 1, 0); // Re-anchor to the previous line.
}
return active;
};
const lookAhead: CoordMapper = (active, helper) => {
const { line } = active;
if (helper.editor.document.lineAt(line).text.length === 0) {
return new Coord(line + 1, 0);
}
return active;
};
const toCurrentStart: CoordMapper = (active, helper) => {
const { document } = helper.editor;
let { line } = active;
// Move past any trailing empty lines.
while (line >= 0 && document.lineAt(line).text.length === 0) {
line--;
}
if (line <= 0) {
return DocumentStart;
}
// Then move to the start of the paragraph (non-empty lines).
while (line > 0 && document.lineAt(line - 1).text.length > 0) {
line--;
}
return new Coord(line, 0);
};
function toEnd(inner: boolean): CoordMapper {
return (active, helper) => {
const { document } = helper.editor;
let { line } = active;
// Move to the end of the paragraph (non-empty lines)
while (line < document.lineCount && document.lineAt(line).text.length > 0) {
line++;
}
if (line >= document.lineCount) {
return helper.lastCoord();
}
if (inner) {
if (line > 0) {
line--;
}
return new Coord(line, document.lineAt(line).text.length);
}
// Then move to the last trailing empty line.
while (line + 1 < document.lineCount && document.lineAt(line + 1).text.length === 0) {
line++;
}
return new Coord(line, document.lineAt(line).text.length);
};
}
function selectToEdge(direction: Direction, adjust: CoordMapper, toEdge: CoordMapper) {
return {
extend: moveActiveCoord((oldActive, helper, i) => {
const adjusted = adjust(oldActive, helper, i);
if ("remove" in adjusted) {
return RemoveSelection;
}
if (
direction === Forward
&& helper.editor.document.lineAt(adjusted.line).text.length === 0
) {
return toEdge(new Coord(adjusted.line + 1, 0), helper, i);
} else {
return toEdge(adjusted, helper, i);
}
}, Extend),
doNotExtend: seekToRange((oldActive, helper, i) => {
const anchor = adjust(oldActive, helper, i);
if ("remove" in anchor) {
return RemoveSelection;
}
let active;
if (direction === Forward && helper.editor.document.lineAt(anchor.line).text.length === 0) {
active = toEdge(new Coord(anchor.line + 1, 0), helper, i);
} else {
active = toEdge(anchor, helper, i);
}
if ("remove" in active) {
return RemoveSelection;
}
return [anchor, active];
}, DoNotExtend),
};
}
function select(inner: boolean): SelectionMapper {
const toEndFunc = toEnd(inner);
return (selection, helper, i) => {
const active = helper.activeCoord(selection);
const { document } = helper.editor;
let start;
if (
active.line + 1 < document.lineCount
&& document.lineAt(active.line).text.length === 0
&& document.lineAt(active.line + 1).text.length
) {
// Special case: if current line is empty, check next line and select
// the NEXT paragraph if next line is not empty.
start = new Coord(active.line + 1, 0);
} else {
const startResult = toCurrentStart(active, helper, i);
if ("remove" in startResult) {
return RemoveSelection;
}
start = startResult;
}
// It's just much easier to check from start.
const end = toEndFunc(start, helper, i);
if ("remove" in end) {
return RemoveSelection;
}
return helper.selectionBetween(start, end);
};
}
const selectToStart = selectToEdge(Backward, lookBack, toCurrentStart);
return {
select: {
outer: select(/* inner = */ false),
inner: select(/* inner = */ true),
},
selectToEnd: {
outer: selectToEdge(Forward, lookAhead, toEnd(/* inner = */ false)),
inner: selectToEdge(Forward, lookAhead, toEnd(/* inner = */ true)),
},
selectToStart: {
outer: selectToStart,
inner: selectToStart,
},
};
}
function whitespacesObject() {
// The "inner" versions of a whitespaces object excludes all line breaks and
// the "outer" versions includes line breaks as well. Unlike other objects,
// there are no actual "surrounding" parts of objects.
// The objectWithCharSet helper function can handle the inline whitespaces.
const actions = objectWithCharSet(CharSet.Blank);
// Let's then overwrite logic for "outer" actions to include line breaks.
const toStart: CoordMapper = (active, helper) => {
const { document } = helper.editor;
const isBlank = getCharSetFunction(CharSet.Blank, document);
const afterSkip = skipWhileX(Backward, active, isBlank, document);
if (!afterSkip) {
return DocumentStart;
}
if (afterSkip.isEqual(active)) {
return RemoveSelection;
}
return helper.nextPos(afterSkip);
};
const toEnd: CoordMapper = (active, helper) => {
const { document } = helper.editor;
const isBlank = getCharSetFunction(CharSet.Blank, document);
const afterSkip = skipWhileX(Forward, active, isBlank, document);
if (!afterSkip) {
return helper.lastCoord();
}
if (afterSkip.isEqual(active)) {
return RemoveSelection;
}
return helper.prevPos(afterSkip);
};
actions.select.outer = (selection, helper, i) => {
const active = helper.activeCoord(selection);
const start = toStart(active, helper, i);
const end = toEnd(active, helper, i);
if ("remove" in start || "remove" in end) {
return RemoveSelection;
}
return helper.selectionBetween(start, end);
};
actions.selectToStart.outer = {
doNotExtend: moveActiveCoord(toStart, DoNotExtend),
extend: moveActiveCoord(toStart, Extend),
};
actions.selectToEnd.outer = {
doNotExtend: moveActiveCoord(toEnd, DoNotExtend),
extend: moveActiveCoord(toEnd, Extend),
};
return actions;
}
function indentObject() {
function toEdge(direction: Direction, inner: boolean): CoordMapper {
return (oldActive, helper) => {
const { document } = helper.editor;
let { line } = oldActive;
let lineObj = document.lineAt(line);
// First, scan backwards through blank lines. (Note that whitespace-only
// lines do not count -- those have a proper indentation level and should
// be treated as the inner part of the indent block.)
while (lineObj.text.length === 0) {
line += direction;
if (line < 0) {
return DocumentStart;
}
if (line >= document.lineCount) {
return helper.lastCoord();
}
lineObj = document.lineAt(line);
}
const indent = lineObj.firstNonWhitespaceCharacterIndex;
let lastNonBlankLine = line;
for (;;) {
line += direction;
if (line < 0) {
return DocumentStart;
}
if (line >= document.lineCount) {
return helper.lastCoord();
}
lineObj = document.lineAt(line);
if (lineObj.text.length === 0) {
continue;
}
if (lineObj.firstNonWhitespaceCharacterIndex < indent) {
const resultLine = inner ? lastNonBlankLine : line - direction;
if (direction === Forward && resultLine + 1 === document.lineCount) {
return helper.lastCoord();
}
const resultCol = direction === Backward ? 0 : document.lineAt(resultLine).text.length;
return new Coord(resultLine, resultCol);
}
lastNonBlankLine = line;
}
};
}
const toStart = toEdge(Backward, false),
toStartInner = toEdge(Backward, true),
toEnd = toEdge(Forward, false),
toEndInner = toEdge(Forward, true);
// When selecting a whole indent object, scanning separately toStart and then
// toEnd will lead to wrong results like two different indentation levels and
// skipping over blank lines more than needed. We can mitigate this by finding
// the start first and then scan from there to find the end of indent block.
const scanFromStart = true;
return objectActions(toStart, toEnd, toStartInner, toEndInner, scanFromStart);
}
function numberObject() {
// TODO: Handle optional leading minus sign for numbers.
const numberCharCodes = new Uint32Array(Array.from("0123456789.", (ch) => ch.charCodeAt(0)));
// Numbers cannot have trailing whitespaces even for outer in Kakoune, and
// let's match the behavior here.
const actions = objectWithCharSet((charCode) => numberCharCodes.indexOf(charCode) >= 0);
actions.select.outer = actions.select.inner;
actions.selectToEnd.outer = actions.selectToEnd.inner;
actions.selectToStart.outer = actions.selectToStart.inner;
return actions;
}
function argumentObject() {
function toEdge(direction: Direction, inner: boolean): CoordMapper {
const paren = direction === Backward ? LPAREN : RPAREN;
return (oldActive, helper) => {
const { document } = helper.editor;
let bbalance = 0,
pbalance = 0;
const afterSkip = skipWhile(
direction,
oldActive,
(charCode) => {
// TODO: Kak does not care about strings or ignoring braces in strings
// but maybe we should add a setting or an alternative command for
// that.
if (charCode === paren && pbalance === 0 && bbalance === 0) {
return false;
} else if (charCode === LPAREN) {
pbalance++;
} else if (charCode === LSQBRACKET) {
bbalance++;
} else if (charCode === RPAREN) {
pbalance--;
} else if (charCode === RSQBRACKET) {
bbalance--;
} else if (pbalance !== 0 || bbalance !== 0) {
// Nop.
} else if (charCode === COMMA) {
return false;
}
return true;
},
helper.editor.document,
);
let end;
if (afterSkip === undefined) {
end = direction === Backward ? DocumentStart : helper.lastCoord();
} else {
const charCode = document.lineAt(afterSkip.line).text.charCodeAt(afterSkip.character);
// Make sure parens are not included in the object. Deliminator commas
// after the argument is included as outer, but ones before are NOT.
// TODO: Kakoune seems to have more sophisticated edge cases for commas,
// e.g. outer last argument includes the comma before it, plus more edge
// cases for who owns the whitespace. Those are not implemented for now
// because they require extensive tests and mess a lot with the logic of
// selecting the whole object.
if (inner || charCode === paren || direction === Backward) {
end = direction === Backward ? helper.nextPos(afterSkip) : helper.prevPos(afterSkip);
} else {
end = afterSkip;
}
}
if (!inner) {
return end;
}
const isBlank = getCharSetFunction(CharSet.Blank, document);
// Exclude any surrounding whitespaces.
end = skipWhileX(-direction, end, isBlank, helper.editor.document);
if (!end) {
return direction === Backward ? DocumentStart : helper.lastCoord();
}
return end;
};
}
const toStart = toEdge(Backward, false),
toStartInner = toEdge(Backward, true),
toEnd = toEdge(Forward, false),
toEndInner = toEdge(Forward, true);
return objectActions(toStart, toEnd, toStartInner, toEndInner);
}
type ObjectAction = "select" | "selectToStart" | "selectToEnd";
const dispatch = {
parens: objectWithinPair(LPAREN, RPAREN),
braces: objectWithinPair(LCRBRACKET, RCRBRACKET),
brackets: objectWithinPair(LSQBRACKET, RSQBRACKET),
angleBrackets: objectWithinPair(LCHEVRON, RCHEVRON),
doubleQuoteString: objectWithinPair(QUOTE_DBL, QUOTE_DBL),
singleQuoteString: objectWithinPair(QUOTE_SGL, QUOTE_SGL),
graveQuoteString: objectWithinPair(BACKTICK, BACKTICK),
word: objectWithCharSet(CharSet.Word),
WORD: objectWithCharSet(CharSet.NonBlank),
sentence: sentenceObject(),
paragraph: paragraphObject(),
whitespaces: whitespacesObject(),
indent: indentObject(),
number: numberObject(),
argument: argumentObject(),
// TODO: custom
};
registerCommand(
Command.objectsPerformSelection,
CommandFlags.ChangeSelections,
(editorState, state) => {
if (!state.argument || !state.argument.object) {
throw new Error(
"Argument must have shape "
+ "{object: string, action: string, extend?: boolean, inner?: boolean}",
);
}
const dispatch2 = dispatch[state.argument.object as keyof typeof dispatch];
if (!dispatch2) {
throw new Error(
"Invalid argument: object must be a string and one of " + Object.keys(dispatch).join(","),
);
}
const dispatch3 = dispatch2[state.argument.action as ObjectAction];
if (!dispatch3) {
throw new Error(
"Invalid argument: action must be a string and one of " + Object.keys(dispatch2).join(","),
);
}
const bound = state.argument.inner ? "inner" : "outer";
let mapper = dispatch3[bound];
if (typeof mapper === "object") {
const extend = state.argument.extend ? "extend" : "doNotExtend";
mapper = mapper[extend];
}
const helper = SelectionHelper.for(editorState, state);
helper.mapEach(mapper);
},
);

View File

@ -0,0 +1,62 @@
import { Argument } from ".";
import { Context, rotate } from "../api";
/**
* Rotate selection indices and contents.
*/
declare module "./selections.rotate";
/**
* Rotate selections clockwise.
*
* @keys `(` (normal)
*
* The following keybinding is also available:
*
* | Title | Identifier | Keybinding | Command |
* | ----------------------------------- | -------------- | ------------ | ------------------------------------------- |
* | Rotate selections counter-clockwise | `both.reverse` | `)` (normal) | `[".selections.rotate", { reverse: true }]` |
*/
export function both(_: Context, repetitions: number, reverse: Argument<boolean> = false) {
if (reverse) {
repetitions = -repetitions;
}
return rotate(repetitions);
}
/**
* Rotate selections clockwise (contents only).
*
* The following command is also available:
*
* | Title | Identifier | Command |
* | --------------------------------------------------- | ------------------ | ---------------------------------------------------- |
* | Rotate selections counter-clockwise (contents only) | `contents.reverse` | `[".selections.rotate.contents", { reverse: true }]` |
*/
export function contents(_: Context, repetitions: number, reverse: Argument<boolean> = false) {
if (reverse) {
repetitions = -repetitions;
}
return rotate.contentsOnly(repetitions);
}
/**
* Rotate selections clockwise (selections only).
*
* @keys `a-(` (normal)
*
* The following keybinding is also available:
*
* | Title | Identifier | Keybinding | Command |
* | ----------------------------------------------------- | -------------------- | -------------- | ------------------------------------------------------ |
* | Rotate selections counter-clockwise (selections only) | `selections.reverse` | `a-)` (normal) | `[".selections.rotate.selections", { reverse: true }]` |
*/
export function selections(_: Context, repetitions: number, reverse: Argument<boolean> = false) {
if (reverse) {
repetitions = -repetitions;
}
return rotate.selectionsOnly(repetitions);
}

File diff suppressed because it is too large Load Diff

29
src/commands/view.ts Normal file
View File

@ -0,0 +1,29 @@
import * as vscode from "vscode";
import { Argument } from ".";
import { Context, Selections } from "../api";
/**
* Moving the editor view.
*
* #### Predefined keybindings
*
* | Title | Keybinding | Command |
* | ----------------------- | -------------- | ------------------------------------------------ |
* | Show view menu | `v` (normal) | `[".openMenu", { input: "view" }]` |
* | Show view menu (locked) | `s-v` (normal) | `[".openMenu", { input: "view", locked: true }]` |
*/
declare module "./view";
/**
* Reveals a position based on the main cursor.
*/
export function line(
_: Context,
at: Argument<"top" | "center" | "bottom"> = "center",
) {
return vscode.commands.executeCommand(
"revealLine",
{ at, lineNumber: Selections.activeLine(_.mainSelection) },
);
}

View File

@ -1,326 +0,0 @@
import * as vscode from "vscode";
import { Command, CommandFlags, CommandState, InputKind, UndoStops, registerCommand } from ".";
import { Extension } from "../state/extension";
import { SelectionHelper } from "../utils/selectionHelper";
function getRegister(state: CommandState<any>, ctx: Extension) {
return state.currentRegister || ctx.registers.dquote;
}
function deleteSelections(editor: vscode.TextEditor, undoStops: UndoStops) {
return editor.edit((builder) => {
const selections = editor.selections,
len = selections.length;
for (let i = 0; i < len; i++) {
builder.delete(selections[i]);
}
}, undoStops);
}
registerCommand(
Command.deleteYank,
CommandFlags.Edit,
async ({ editor, extension }, state, undoStops) => {
const reg = getRegister(state, extension);
if (reg.canWrite()) {
await reg.set(editor, editor.selections.map(editor.document.getText));
}
await deleteSelections(editor, undoStops);
},
);
registerCommand(
Command.deleteInsertYank,
CommandFlags.Edit | CommandFlags.SwitchToInsertBefore,
async ({ editor, extension }, state, undoStops) => {
const reg = getRegister(state, extension);
if (reg.canWrite()) {
await reg.set(editor, editor.selections.map(editor.document.getText));
}
await deleteSelections(editor, undoStops);
},
);
registerCommand(Command.deleteNoYank, CommandFlags.Edit, ({ editor }, _, undoStops) => {
return deleteSelections(editor, undoStops).then(() => undefined);
});
registerCommand(
Command.deleteInsertNoYank,
CommandFlags.Edit | CommandFlags.SwitchToInsertBefore,
({ editor }, _, undoStops) => {
return deleteSelections(editor, undoStops).then(() => undefined);
},
);
registerCommand(Command.yank, CommandFlags.None, ({ editor, extension }, state) => {
const reg = getRegister(state, extension);
if (reg.canWrite()) {
return reg.set(editor, editor.selections.map(editor.document.getText));
}
return undefined;
});
async function getContentsToPaste(
editor: vscode.TextEditor,
state: CommandState<any>,
ctx: Extension,
) {
const yanked = await getRegister(state, ctx).get(editor);
const amount = editor.selections.length;
if (yanked === undefined) {
return undefined;
}
const results = [] as string[],
yankedLength = yanked.length;
let i = 0;
for (; i < amount && i < yankedLength; i++) {
results.push(yanked[i]);
}
for (; i < amount; i++) {
results.push(yanked[yankedLength - 1]);
}
return results;
}
registerCommand(Command.pasteAfter, CommandFlags.Edit, async (editorState, state, undoStops) => {
const { editor, extension } = editorState,
selections = editor.selections,
selectionHelper = SelectionHelper.for(editorState, state);
const contents = await getContentsToPaste(editor, state, extension);
if (contents === undefined) {
return;
}
const selectionLengths = [] as number[];
await editor.edit((builder) => {
for (let i = 0; i < contents.length; i++) {
const content = contents[i],
selection = selections[i];
if (content.endsWith("\n")) {
builder.insert(new vscode.Position(selectionHelper.endLine(selection) + 1, 0), content);
} else {
builder.insert(selection.end, content);
}
selectionLengths.push(selectionHelper.selectionLength(selection));
}
}, undoStops);
// Restore selections that were extended automatically.
for (let i = 0; i < contents.length; i++) {
selections[i] = selectionHelper.selectionFromLength(selections[i].anchor, selectionLengths[i]);
}
editor.selections = selections;
});
registerCommand(
Command.pasteBefore,
CommandFlags.Edit,
async ({ editor, extension }, state, undoStops) => {
const contents = await getContentsToPaste(editor, state, extension);
if (contents === undefined) {
return;
}
await editor.edit((builder) => {
for (let i = 0; i < contents.length; i++) {
const content = contents[i],
selection = editor.selections[i];
if (content.endsWith("\n")) {
builder.insert(selection.start.with(undefined, 0), content);
} else {
builder.insert(selection.start, content);
}
}
}, undoStops);
},
);
registerCommand(
Command.pasteSelectAfter,
CommandFlags.ChangeSelections | CommandFlags.Edit,
async (editorState, state, undoStops) => {
const { editor, extension } = editorState,
contents = await getContentsToPaste(editor, state, extension);
if (contents === undefined) {
return;
}
const reverseSelection = [] as boolean[],
selectionHelper = SelectionHelper.for(editorState, state);
await editor.edit((builder) => {
for (let i = 0; i < contents.length; i++) {
const content = contents[i],
selection = editor.selections[i];
if (content.endsWith("\n")) {
builder.insert(selection.end.with(selectionHelper.endLine(selection) + 1, 0), content);
} else {
builder.insert(selection.end, content);
}
reverseSelection.push(selection.isEmpty);
}
}, undoStops);
// Reverse selections that were empty, since they are now extended in the
// wrong way.
for (let i = 0; i < contents.length; i++) {
const content = contents[i];
if (!content.endsWith("\n") && reverseSelection[i]) {
editor.selections[i] = new vscode.Selection(
editor.selections[i].active,
editor.selections[i].anchor,
);
}
}
// eslint-disable-next-line no-self-assign
editor.selections = editor.selections; // Force update.
},
);
registerCommand(
Command.pasteSelectBefore,
CommandFlags.ChangeSelections | CommandFlags.Edit,
async ({ editor, extension }, state, undoStops) => {
const contents = await getContentsToPaste(editor, state, extension);
if (contents === undefined) {
return;
}
await editor.edit((builder) => {
for (let i = 0; i < contents.length; i++) {
const content = contents[i],
selection = editor.selections[i];
if (content.endsWith("\n")) {
builder.replace(selection.start.with(undefined, 0), content);
} else {
builder.replace(selection.start, content);
}
}
}, undoStops);
},
);
registerCommand(
Command.pasteReplace,
CommandFlags.Edit,
async ({ editor, extension }, state, undoStops) => {
const contents = await getContentsToPaste(editor, state, extension);
if (contents === undefined) {
return;
}
const reg = getRegister(state, extension);
if (reg.canWrite()) {
await reg.set(editor, editor.selections.map(editor.document.getText));
}
await editor.edit((builder) => {
for (let i = 0; i < contents.length; i++) {
const content = contents[i],
selection = editor.selections[i];
builder.replace(selection, content);
}
}, undoStops);
},
);
registerCommand(
Command.pasteReplaceEvery,
CommandFlags.Edit,
async ({ editor, extension }, state, undoStops) => {
const selections = editor.selections;
const contents = await getRegister(state, extension).get(editor);
if (contents === undefined || contents.length !== selections.length) {
return;
}
await editor.edit((builder) => {
for (let i = 0; i < contents.length; i++) {
builder.replace(selections[i], contents[i]);
}
}, undoStops);
},
);
registerCommand(
Command.replaceCharacters,
CommandFlags.Edit,
InputKind.Key,
() => void 0,
({ editor }, { repetitions, input: key }, undoStops) => {
const string = key.repeat(repetitions);
return editor
.edit((builder) => {
for (const selection of editor.selections) {
let i = selection.start.line;
if (selection.end.line === i) {
// A single line-selection; replace the selection directly
builder.replace(
selection,
string.repeat(selection.end.character - selection.start.character),
);
continue;
}
// Replace in first line
const firstLine = editor.document.lineAt(i).range.with(selection.start);
builder.replace(
firstLine,
string.repeat(firstLine.end.character - firstLine.start.character),
);
// Replace in intermediate lines
while (++i < selection.end.line) {
const line = editor.document.lineAt(i);
builder.replace(line.range, string.repeat(line.text.length));
}
// Replace in last line
const lastLine = editor.document.lineAt(i).range.with(undefined, selection.end);
builder.replace(
lastLine,
string.repeat(lastLine.end.character - lastLine.start.character),
);
}
}, undoStops)
.then(() => void 0);
},
);

View File

@ -1,5 +1,7 @@
import * as vscode from "vscode";
import * as api from "./api";
import { loadCommands } from "./commands/load-all";
import { Extension } from "./state/extension";
/**
@ -10,37 +12,41 @@ export const extensionName = "dance";
/**
* Global state of the extension.
*/
export let extensionState: Extension;
export let extensionState: Extension | undefined;
let isActivated = false;
/**
* Function called by VS Code to activate the extension.
*/
export function activate(context: vscode.ExtensionContext) {
extensionState = new Extension();
export function activate() {
isActivated = true;
context.subscriptions.push(
vscode.commands.registerCommand(extensionName + ".toggle", () =>
extensionState.setEnabled(!extensionState.enabled, false),
),
);
const extensionData = vscode.extensions.getExtension(`gregoire.${extensionName}`),
extensionPackageJSON = extensionData?.packageJSON;
if (process.env.VERBOSE_LOGGING === "true") {
// Log all commands we need to implement
Promise.all([vscode.commands.getCommands(true), import("../commands/index")]).then(
([registeredCommands, { commands }]) => {
for (const command of Object.values(commands)) {
if (registeredCommands.indexOf(command.id) === -1) {
console.warn("Command", command.id, "is defined but not implemented.");
}
}
},
);
if (extensionPackageJSON?.[`${extensionName}.disableArbitraryCodeExecution`]) {
api.run.disable();
}
if (extensionPackageJSON?.[`${extensionName}.disableArbitraryCommandExecution`]) {
api.execute.disable();
}
return loadCommands().then((commands) => {
if (isActivated) {
return { api, extension: (extensionState = new Extension(commands)) };
}
return;
});
}
/**
* Function called by VS Code to deactivate the extension.
*/
export function deactivate() {
extensionState.dispose();
isActivated = false;
extensionState?.dispose();
}
export { api };

View File

@ -1,147 +0,0 @@
import * as vscode from "vscode";
import { CommandState } from "./commands";
export interface Register {
readonly name: string;
canWrite(): this is WritableRegister;
get(editor: vscode.TextEditor): Thenable<string[] | undefined>;
}
export interface MacroRegister {
getMacro(): CommandState<any>[] | undefined;
setMacro(data: CommandState<any>[]): void;
}
export interface WritableRegister extends Register {
set(editor: vscode.TextEditor, values: string[]): Thenable<void>;
}
export class GeneralPurposeRegister implements Register, WritableRegister, MacroRegister {
public values: string[] | undefined;
public macroCommands: CommandState<any>[] | undefined;
public canWrite() {
return !this.readonly;
}
public constructor(public readonly name: string, public readonly readonly = false) {}
public set(_: vscode.TextEditor, values: string[]) {
this.values = values;
return Promise.resolve();
}
public get() {
return Promise.resolve(this.values);
}
public getMacro() {
return this.macroCommands;
}
public setMacro(data: CommandState<any>[]) {
this.macroCommands = data;
}
}
export class SpecialRegister implements Register {
public canWrite() {
return this.setter !== undefined;
}
public constructor(
public readonly name: string,
public readonly getter: (editor: vscode.TextEditor) => Thenable<string[]>,
public readonly setter?: (editor: vscode.TextEditor, values: string[]) => Thenable<void>,
) {}
public get(editor: vscode.TextEditor) {
return this.getter(editor);
}
public set(editor: vscode.TextEditor, values: string[]) {
if (this.setter === undefined) {
throw new Error("Cannot set read-only register.");
}
return this.setter(editor, values);
}
}
export class ClipboardRegister implements Register {
private lastSelections!: string[];
private lastText!: string;
public readonly name = '"';
public canWrite() {
return true;
}
public async get() {
const text = await vscode.env.clipboard.readText();
return this.lastText === text ? this.lastSelections : [text];
}
public set(editor: vscode.TextEditor, values: string[]) {
this.lastSelections = values;
this.lastText = values.join(editor.document.eol === 1 ? "\n" : "\r\n");
return vscode.env.clipboard.writeText(this.lastText);
}
}
export class Registers {
public readonly alpha: Record<string, GeneralPurposeRegister> = {};
public readonly dquote = new ClipboardRegister();
public readonly slash = new GeneralPurposeRegister("/");
public readonly arobase = new GeneralPurposeRegister("@");
public readonly caret = new GeneralPurposeRegister("^");
public readonly pipe = new GeneralPurposeRegister("|");
public readonly percent = new SpecialRegister("%", (editor) =>
Promise.resolve([editor.document.fileName]),
);
public readonly dot = new SpecialRegister(".", (editor) =>
Promise.resolve(editor.selections.map(editor.document.getText)),
);
public readonly hash = new SpecialRegister("#", (editor) =>
Promise.resolve(editor.selections.map((_, i) => i.toString())),
);
public readonly underscore = new SpecialRegister("_", (______) => Promise.resolve([""]));
public readonly colon = new GeneralPurposeRegister(":", true);
public get(key: string) {
switch (key) {
case '"':
return this.dquote;
case "/":
return this.slash;
case "@":
return this.arobase;
case "^":
return this.caret;
case "|":
return this.pipe;
case "%":
return this.percent;
case ".":
return this.dot;
case "#":
return this.hash;
case "_":
return this.underscore;
case ":":
return this.colon;
default:
return this.alpha[key] || (this.alpha[key] = new GeneralPurposeRegister(key));
}
}
}

View File

@ -1,186 +0,0 @@
import * as vscode from "vscode";
import { EditorState } from "./editor";
import { Extension } from "./extension";
import { CommandState, InputKind } from "../commands";
import { assert } from "../utils/assert";
import { SavedSelection } from "../utils/savedSelection";
/**
* Document-specific state.
*/
export class DocumentState {
private readonly _editorStates: EditorState[] = [];
private readonly _savedSelections: SavedSelection[] = [];
public constructor(
/** The extension for which state is being kept. */
public readonly extension: Extension,
/** The editor for which state is being kept. */
public readonly document: vscode.TextDocument,
) {}
/**
* Disposes of the resources owned by and of the subscriptions of this
* instance.
*/
public dispose() {
const editorStates = this._editorStates.splice(0);
for (let i = 0, len = editorStates.length; i < len; i++) {
editorStates[i].dispose();
}
this._savedSelections.length = 0;
}
/**
* Returns the `EditorState` of each known `vscode.TextEditor`, where
* `editor.document === this.document`.
*/
public editorStates() {
return this._editorStates as readonly EditorState[];
}
/**
* Gets the `EditorState` for the given `vscode.TextEditor`, where
* `editor.document === this.document`.
*/
public getEditorState(editor: vscode.TextEditor) {
assert(editor.document === this.document);
const editorStates = this._editorStates,
len = editorStates.length;
for (let i = 0; i < len; i++) {
const editorState = editorStates[i];
if (editorState.isFor(editor)) {
return editorState.updateEditor(editor);
}
}
const editorState = new EditorState(this, editor);
this._editorStates.push(editorState);
return editorState;
}
/**
* Called when `vscode.workspace.onDidChangeTextDocument` is triggered.
*/
public onDidChangeTextDocument(e: vscode.TextDocumentChangeEvent) {
const savedSelections = this._savedSelections;
if (savedSelections !== undefined) {
for (let i = 0; i < savedSelections.length; i++) {
const savedSelection = savedSelections[i];
for (let j = 0; j < e.contentChanges.length; j++) {
savedSelection.updateAfterDocumentChanged(e.contentChanges[j]);
}
}
}
const editorStates = this._editorStates;
for (let i = 0; i < editorStates.length; i++) {
editorStates[i].onDidChangeTextDocument(e);
}
this.recordChanges(e.contentChanges);
}
// =============================================================================================
// == SAVED SELECTIONS =======================================================================
// =============================================================================================
/**
* Saves the given selection, tracking changes to the given document and
* updating the selection correspondingly over time.
*/
public saveSelection(selection: vscode.Selection) {
const anchorOffset = this.document.offsetAt(selection.anchor),
activeOffset = this.document.offsetAt(selection.active),
savedSelection = new SavedSelection(anchorOffset, activeOffset);
this._savedSelections.push(savedSelection);
return savedSelection;
}
/**
* Forgets the given saved selections.
*/
public forgetSelections(selections: readonly SavedSelection[]) {
const savedSelections = this._savedSelections;
if (savedSelections !== undefined) {
for (let i = 0; i < selections.length; i++) {
const index = savedSelections.indexOf(selections[i]);
if (index !== -1) {
savedSelections.splice(index, 1);
}
}
}
}
// =============================================================================================
// == HISTORY ================================================================================
// =============================================================================================
private _lastCommand?: CommandState<any>;
private readonly _recordedChanges = [] as RecordedChange[];
/**
* The changes that were last made in this editor.
*/
public get recordedChanges() {
return this._recordedChanges as readonly RecordedChange[];
}
/**
* Adds the given changes to the history of the editor following the given
* command.
*/
private recordChanges(changes: readonly vscode.TextDocumentContentChangeEvent[]) {
this._lastCommand?.recordFollowingChanges(changes);
const recordedChanges = this._recordedChanges;
if (recordedChanges.length + changes.length > 100) {
recordedChanges.splice(0, recordedChanges.length + changes.length - 100);
}
for (let i = 0, len = changes.length; i < len; i++) {
const change = changes[i],
savedSelection = new SavedSelection(
change.rangeOffset,
change.rangeOffset + change.rangeLength,
);
this._savedSelections.push(savedSelection);
recordedChanges.push(new RecordedChange(savedSelection, change.text));
}
}
/**
* Records invocation of a command.
*/
public recordCommand<I extends InputKind>(state: CommandState<I>) {
this._lastCommand = state;
}
}
export class RecordedChange {
public constructor(
/** The range that got replaced. */
public readonly range: SavedSelection,
/** The new text for the range. */
public readonly text: string,
) {}
}

View File

@ -1,584 +0,0 @@
import * as vscode from "vscode";
import { DocumentState } from "./document";
import { Mode, SelectionBehavior } from "./extension";
import { Command, CommandState, InputKind } from "../commands";
import { extensionName } from "../extension";
import { assert } from "../utils/assert";
import { SavedSelection } from "../utils/savedSelection";
import { MacroRegister } from "../registers";
/**
* Editor-specific state.
*/
export class EditorState {
/**
* The internal identifir of the ID.
*
* Unlike documents, VS Code does not reuse `vscode.TextEditor` objects,
* so comparing by reference using `===` may not always return `true`,
* even for the same document. To keep editor-specific state anyway,
* we're using its internal identifier, which seems to be unique and
* to stay the same over time.
*/
private _id: string;
/** The last matching editor. */
private _editor: vscode.TextEditor;
/** Selections that we had before entering insert mode. */
private _insertModeSelections?: readonly SavedSelection[];
/**
* Whether a selection change event should be expected while in insert mode.
*/
private _expectSelectionChangeEvent = false;
/** Whether the next selection change event should be ignored. */
private _ignoreSelectionChangeEvent = false;
/** The ongoing recording of a macro in this editor. */
private _macroRecording?: MacroRecording;
private _mode!: Mode;
/**
* The mode of the editor.
*/
public get mode() {
return this._mode;
}
/**
* The extension for which state is being kept.
*/
public get extension() {
return this.documentState.extension;
}
/**
* The editor for which state is being kept.
*/
public get editor() {
return this._editor;
}
/**
* Whether the editor for which state is being kept is the active text editor.
*/
public get isActive() {
return vscode.window.activeTextEditor === this._editor;
}
public get selectionBehavior() {
return this.documentState.extension.selectionBehavior;
}
/**
* Preferred columns when navigating up and down.
*/
public readonly preferredColumns: number[] = [];
public constructor(
/** The state of the document for which this editor exists. */
public readonly documentState: DocumentState,
/** The text editor for which state is being kept. */
editor: vscode.TextEditor,
) {
this._id = getEditorId(editor);
this._editor = editor;
this.setMode(Mode.Normal);
}
/**
* Disposes of the resources owned by and of the subscriptions of this
* instance.
*/
public dispose() {
const lineNumbering = vscode.workspace.getConfiguration("editor").get("lineNumbers"),
options = this._editor.options;
options.lineNumbers
= lineNumbering === "on"
? vscode.TextEditorLineNumbersStyle.On
: lineNumbering === "relative"
? vscode.TextEditorLineNumbersStyle.Relative
: lineNumbering === "interval"
? vscode.TextEditorLineNumbersStyle.Relative + 1
: vscode.TextEditorLineNumbersStyle.Off;
this.clearDecorations(this.extension.normalMode.decorationType);
this.clearDecorations(this.extension.insertMode.decorationType);
this._macroRecording?.dispose();
}
/**
* Updates the instance of `vscode.TextEditor` for this editor.
*/
public updateEditor(editor: vscode.TextEditor) {
assert(this.isFor(editor));
this._editor = editor;
return this;
}
/**
* Returns whether this state is for the given editor.
*/
public isFor(editor: vscode.TextEditor) {
return this.documentState.document === editor.document && this._id === getEditorId(editor);
}
/**
* Sets the mode of the editor.
*/
public setMode(mode: Mode) {
if (this._mode === mode) {
return;
}
const { insertMode, normalMode } = this.extension,
documentState = this.documentState;
this._mode = mode;
if (mode === Mode.Insert) {
this.clearDecorations(normalMode.decorationType);
this.setDecorations(insertMode.decorationType);
const selections = this.editor.selections,
documentState = this.documentState,
savedSelections = [] as SavedSelection[];
for (let i = 0, len = selections.length; i < len; i++) {
savedSelections.push(documentState.saveSelection(selections[i]));
}
this._insertModeSelections = savedSelections;
this._ignoreSelectionChangeEvent = true;
if (this.extension.insertModeSelectionStyle !== undefined) {
this.editor.setDecorations(this.extension.insertModeSelectionStyle, selections);
}
} else {
if (this._insertModeSelections !== undefined && this._insertModeSelections.length > 0) {
const savedSelections = this._insertModeSelections,
editorSelections = this._editor.selections,
document = this.documentState.document;
assert(editorSelections.length === savedSelections.length);
for (let i = 0, len = savedSelections.length; i < len; i++) {
editorSelections[i] = savedSelections[i].selection(document);
}
documentState.forgetSelections(this._insertModeSelections);
this._editor.selections = editorSelections;
this._insertModeSelections = undefined;
}
this.clearDecorations(insertMode.decorationType);
this.clearDecorations(this.extension.insertModeSelectionStyle);
this.setDecorations(normalMode.decorationType);
this.normalizeSelections();
}
if (this.isActive) {
this.onDidBecomeActive();
}
}
/**
* Starts recording a macro, setting up relevant handlers and UI elements.
*/
public startMacroRecording(register: MacroRegister & { readonly name: string }) {
if (this._macroRecording !== undefined) {
return undefined;
}
const statusBarItem = vscode.window.createStatusBarItem();
statusBarItem.command = Command.macrosRecordStop;
statusBarItem.text = `Macro recording in ${register.name}`;
this._macroRecording = new MacroRecording(register, this._commands.length, statusBarItem);
return this._macroRecording.show().then(() => this._macroRecording);
}
/**
* Stops recording a macro, disposing of its resources.
*/
public stopMacroRecording() {
const recording = this._macroRecording;
if (recording === undefined) {
return undefined;
}
this._macroRecording = undefined;
return recording.dispose().then(() => recording);
}
/**
* Called when `vscode.window.onDidChangeActiveTextEditor` is triggered with
* this editor.
*/
public onDidBecomeActive() {
const { editor, mode } = this,
modeConfiguration
= mode === Mode.Insert ? this.extension.insertMode : this.extension.normalMode;
if (mode === Mode.Insert) {
this.extension.statusBarItem.text = "$(pencil) INSERT";
} else if (mode === Mode.Normal) {
this.extension.statusBarItem.text = "$(beaker) NORMAL";
}
this._macroRecording?.show();
editor.options.lineNumbers = modeConfiguration.lineNumbers;
editor.options.cursorStyle = modeConfiguration.cursorStyle;
vscode.commands.executeCommand("setContext", extensionName + ".mode", mode);
}
/**
* Called when `vscode.window.onDidChangeActiveTextEditor` is triggered with
* another editor.
*/
public onDidBecomeInactive() {
if (this.mode === Mode.Awaiting) {
this.setMode(Mode.Normal);
}
this._macroRecording?.hide();
}
/**
* Called when `vscode.window.onDidChangeTextEditorSelection` is triggered.
*/
public onDidChangeTextEditorSelection(e: vscode.TextEditorSelectionChangeEvent) {
const mode = this.mode;
if (mode === Mode.Awaiting) {
this.setMode(Mode.Normal);
}
// Update decorations.
if (mode === Mode.Insert) {
if (this._ignoreSelectionChangeEvent) {
this._ignoreSelectionChangeEvent = false;
return;
}
this.setDecorations(this.extension.insertMode.decorationType);
// Update insert mode decorations that keep track of previous selections.
const mustDropSelections
= e.kind === vscode.TextEditorSelectionChangeKind.Command
|| e.kind === vscode.TextEditorSelectionChangeKind.Mouse
|| !this._expectSelectionChangeEvent;
const selectionStyle = this.extension.insertModeSelectionStyle,
decorationRanges = [] as vscode.Range[];
if (mustDropSelections) {
this._insertModeSelections = [];
if (selectionStyle !== undefined) {
this.editor.setDecorations(selectionStyle, []);
}
} else if (selectionStyle !== undefined) {
const insertModeSelections = this._insertModeSelections;
if (insertModeSelections !== undefined) {
const document = this.documentState.document;
for (let i = 0, len = insertModeSelections.length; i < len; i++) {
const insertModeSelection = insertModeSelections[i];
if (insertModeSelection.activeOffset !== insertModeSelection.anchorOffset) {
decorationRanges.push(insertModeSelection.selection(document));
}
}
}
this.editor.setDecorations(selectionStyle, decorationRanges);
}
} else {
this.setDecorations(this.extension.normalMode.decorationType);
}
// Debounce normalization.
if (this.normalizeTimeoutToken !== undefined) {
clearTimeout(this.normalizeTimeoutToken);
this.normalizeTimeoutToken = undefined;
}
if (e.kind === vscode.TextEditorSelectionChangeKind.Mouse) {
this.normalizeTimeoutToken = setTimeout(() => {
this.normalizeSelections();
this.normalizeTimeoutToken = undefined;
}, 200);
} else {
this.normalizeSelections();
}
}
/**
* Called when `vscode.workspace.onDidChangeTextDocument` is triggered on the
* document of the editor.
*/
public onDidChangeTextDocument(e: vscode.TextDocumentChangeEvent) {
if (this._mode === Mode.Insert) {
const changes = e.contentChanges;
if (this.editor.selections.length !== changes.length) {
return;
}
// Find all matching selections for the given changes.
// If all selections have a match, we can continue.
const remainingSelections = new Set(this.editor.selections);
for (let i = 0, len = changes.length; i < len; i++) {
const change = changes[i];
for (const selection of remainingSelections) {
if (
selection.active.isEqual(change.range.start)
|| selection.active.isEqual(change.range.end)
) {
remainingSelections.delete(selection);
break;
}
}
if (remainingSelections.size !== len - i - 1) {
return;
}
}
this._expectSelectionChangeEvent = true;
setImmediate(() => (this._expectSelectionChangeEvent = false));
}
}
// =============================================================================================
// == DECORATIONS ============================================================================
// =============================================================================================
private clearDecorations(decorationType: vscode.TextEditorDecorationType | undefined) {
if (decorationType !== undefined) {
this._editor.setDecorations(decorationType, []);
}
}
public setDecorations(decorationType: vscode.TextEditorDecorationType | undefined) {
if (decorationType === undefined) {
return;
}
const editor = this._editor,
selection = editor.selection,
extension = this.extension;
if (
selection.end.character === 0
&& selection.end.line > 0
&& extension.selectionBehavior === SelectionBehavior.Character
) {
editor.setDecorations(decorationType, [
new vscode.Range(selection.start, selection.end.with(selection.end.line - 1, 0)),
]);
editor.options.cursorStyle = vscode.TextEditorCursorStyle.LineThin;
} else {
editor.setDecorations(decorationType, [selection]);
editor.options.cursorStyle
= this.mode === Mode.Insert
? extension.insertMode.cursorStyle
: extension.normalMode.cursorStyle;
}
}
// =============================================================================================
// == HISTORY ================================================================================
// =============================================================================================
private readonly _commands = [] as CommandState<any>[];
/**
* The commands that were last used in this editor, from the earliest to the
* latest.
*/
public get recordedCommands() {
return this._commands as readonly CommandState<any>[];
}
/**
* Records invocation of a command.
*/
public recordCommand<I extends InputKind>(state: CommandState<I>) {
this.documentState.recordCommand(state);
this._commands.push(state);
if (this._macroRecording) {
if (this._commands.length === 50) {
vscode.window.showWarningMessage(
"You're recording a lot of commands. This may increase memory usage.",
);
}
} else {
// If not recording, limit history to 20 items to avoid eating RAM.
while (this._commands.length > 20) {
this._commands.shift();
}
}
}
// =============================================================================================
// == SELECTION NORMALIZATION ================================================================
// =============================================================================================
private normalizeTimeoutToken: NodeJS.Timeout | undefined = undefined;
/**
* Whether selection changes should be ignored, therefore not automatically
* normalizing selections.
*/
public ignoreSelectionChanges = false;
/**
* Make all selections in the editor non-empty by selecting at least one
* character.
*/
public normalizeSelections() {
if (
this._mode !== Mode.Normal
|| this.extension.selectionBehavior === SelectionBehavior.Caret
|| this.ignoreSelectionChanges
) {
return;
}
const editor = this._editor;
// Since this is called every time when selection changes, avoid allocations
// unless really needed and iterate manually without using helper functions.
let normalizedSelections: vscode.Selection[] | undefined = undefined;
for (let i = 0; i < editor.selections.length; i++) {
const selection = editor.selections[i];
const isReversedOneCharacterSelection = selection.isSingleLine
? selection.anchor.character === selection.active.character + 1
: selection.anchor.character === 0
&& selection.anchor.line === selection.active.line + 1
&& editor.document.lineAt(selection.active).text.length === selection.active.character;
if (isReversedOneCharacterSelection) {
if (normalizedSelections === undefined) {
// Change needed. Allocate the new array and copy what we have so far.
normalizedSelections = editor.selections.slice(0, i);
}
normalizedSelections.push(new vscode.Selection(selection.active, selection.anchor));
} else if (selection.isEmpty) {
if (normalizedSelections === undefined) {
// Change needed. Allocate the new array and copy what we have so far.
normalizedSelections = editor.selections.slice(0, i);
}
const active = selection.active;
if (active.character >= editor.document.lineAt(active.line).range.end.character) {
// Selection is at line end. Select line break.
if (active.line === editor.document.lineCount - 1) {
// Selection is at the very end of the document as well. Select the
// last character instead.
if (active.character === 0) {
if (active.line === 0) {
// There is no character in this document, so we give up on
// normalizing.
continue;
} else {
normalizedSelections.push(
new vscode.Selection(
new vscode.Position(active.line - 1, Number.MAX_SAFE_INTEGER),
active,
),
);
}
} else {
normalizedSelections.push(new vscode.Selection(active.translate(0, -1), active));
}
} else {
normalizedSelections.push(
new vscode.Selection(active, new vscode.Position(active.line + 1, 0)),
);
}
} else {
const offset = editor.document.offsetAt(selection.active);
const nextPos = editor.document.positionAt(offset + 1);
if (nextPos.isAfter(selection.active)) {
// Move cursor forward.
normalizedSelections.push(new vscode.Selection(active, active.translate(0, 1)));
} else {
// Selection is at the very end of the document. Select the last
// character instead.
normalizedSelections.push(new vscode.Selection(active.translate(0, -1), active));
}
}
} else if (normalizedSelections !== undefined) {
normalizedSelections.push(selection);
}
}
if (normalizedSelections !== undefined) {
editor.selections = normalizedSelections;
}
}
}
function getEditorId(editor: vscode.TextEditor) {
return ((editor as unknown) as { readonly id: string }).id;
}
/**
* An ongoing recording of a macro.
*/
export class MacroRecording {
public constructor(
public readonly register: MacroRegister,
public lastHistoryEntry: number,
public readonly statusBarItem: vscode.StatusBarItem,
) {}
public show() {
this.statusBarItem.show();
return vscode.commands.executeCommand("setContext", extensionName + ".recordingMacro", true);
}
public hide() {
this.statusBarItem.hide();
return vscode.commands.executeCommand("setContext", extensionName + ".recordingMacro", false);
}
public dispose() {
this.statusBarItem.dispose();
return vscode.commands.executeCommand("setContext", extensionName + ".recordingMacro", false);
}
}

590
src/state/editors.ts Normal file
View File

@ -0,0 +1,590 @@
import * as vscode from "vscode";
import { assert, command, commands, Context, Positions, Selections, selectionsLines } from "../api";
import { extensionName } from "../extension";
import { Extension } from "./extension";
import { Mode, SelectionBehavior } from "./modes";
/**
* Dance-specific state related to a single `vscode.TextEditor`.
*/
export class PerEditorState implements vscode.Disposable {
private readonly _onEditorWasClosed = new vscode.EventEmitter<this>();
private readonly _onVisibilityDidChange = new vscode.EventEmitter<this>();
private _isVisible = true;
private _mode!: Mode;
private _modeChangeSubscription!: vscode.Disposable;
/**
* The corresponding visible `vscode.TextEditor`.
*/
public get editor() {
return this._editor;
}
/**
* The current mode of the editor.
*/
public get mode() {
return this._mode;
}
/**
* Whether the editor for which state is being kept is the active text editor.
*/
public get isActive() {
return vscode.window.activeTextEditor === this._editor;
}
/**
* Whether the editor for which state is being kept is visible.
*/
public get isVisible() {
return this._isVisible;
}
/**
* An event which fires when the `editor` is permanently closed.
*/
public get onEditorWasClosed() {
return this._onEditorWasClosed.event;
}
/**
* An event which fires when the `editor` becomes visible or hidden. Read
* `isVisible` to find the new value.
*/
public get onVisibilityDidChange() {
return this._onVisibilityDidChange.event;
}
public constructor(
public readonly extension: Extension,
private _editor: vscode.TextEditor,
mode: Mode,
) {
for (let i = 0; i < PerEditorState._registeredStates.length; i++) {
this._storage.push(undefined);
}
this.setMode(mode);
}
public dispose() {
const options = this._editor.options,
mode = this._mode,
vscodeMode = mode.modes.vscodeMode;
options.cursorStyle = vscodeMode.cursorStyle;
options.lineNumbers = vscodeMode.lineNumbers;
if (this._isVisible) {
this._isVisible = false;
this._onVisibilityDidChange.fire(this);
}
this._onEditorWasClosed.fire(this);
this._onEditorWasClosed.dispose();
this._onVisibilityDidChange.dispose();
this._modeChangeSubscription.dispose();
for (let i = 0; i < this._storage.length; i++) {
if (PerEditorState._registeredStates[i]) {
(this._storage[i] as vscode.Disposable | undefined)?.dispose();
}
}
}
// Storage.
// =============================================================================================
private static readonly _registeredStates: boolean[] = [];
/**
* Returns a `Token` that can later be used to store editor-specific data.
*/
public static registerState<T>(isDisposable: T extends vscode.Disposable ? true : false) {
return this._registeredStates.push(isDisposable) - 1 as unknown as PerEditorState.Token<T>;
}
private readonly _storage: unknown[] = [];
/**
* Returns the object assigned to the given token.
*/
public get<T>(token: PerEditorState.Token<T>) {
return this._storage[token as unknown as number] as T | undefined;
}
/**
* Stores a value that is related to the editor for which the state is kept.
*/
public store<T>(token: PerEditorState.Token<T>, value: T | undefined) {
const previousValue = this._storage[token as unknown as number];
this._storage[token as unknown as number] = value;
return previousValue as T | undefined;
}
// Changing modes.
// =============================================================================================
/**
* Whether the editor is currently executing functions to change modes.
*/
private _isChangingMode = false;
/**
* Sets the mode of the editor.
*/
public async setMode(mode: Mode) {
if (this._isChangingMode) {
throw new Error("calling EditorState.setMode in a mode change handler is forbidden");
}
if (this._mode === mode) {
return;
}
this._isChangingMode = true;
const previousMode = this._mode;
if (previousMode !== undefined) {
this._modeChangeSubscription.dispose();
this._clearDecorations(previousMode);
await this._runCommands(previousMode.onLeaveMode, (e) =>
`error trying to execute onLeaveMode commands for mode ${
JSON.stringify(previousMode.name)}: ${e}`,
);
if (previousMode.selectionBehavior !== mode.selectionBehavior) {
this._updateSelectionsAfterBehaviorChange(mode);
}
}
this._mode = mode;
this._modeChangeSubscription = mode.onChanged(([mode, props]) => {
for (const prop of props) {
switch (prop) {
case "cursorStyle":
this._editor.options.cursorStyle = mode.cursorStyle;
break;
case "lineNumbers":
this._editor.options.lineNumbers = mode.lineNumbers;
break;
case "decorations":
case "lineHighlight":
case "selectionDecorationType":
this._updateDecorations(mode);
break;
case "selectionBehavior":
this._updateSelectionsAfterBehaviorChange(mode);
break;
}
}
});
this._updateDecorations(mode);
await this._runCommands(mode.onEnterMode, (e) =>
`error trying to execute onEnterMode commands for mode ${JSON.stringify(mode.name)}: ${e}`,
);
if (this.isActive) {
await this.notifyDidBecomeActive();
}
this._isChangingMode = false;
this.extension.editors.notifyDidChangeMode(this);
}
private _runCommands(
commandsToRun: readonly command.Any[],
error: (e: any) => string,
) {
const context = new Context(this, this.extension.cancellationToken);
return this.extension.runPromiseSafely(
() => context.run(() => commands(...commandsToRun)),
() => undefined,
error,
);
}
// Externally-triggered events.
// =============================================================================================
/**
* Called when `vscode.window.onDidChangeActiveTextEditor` is triggered with
* this editor.
*
* @deprecated Do not call -- internal implementation detail.
*/
public notifyDidBecomeActive() {
const { editor, mode } = this;
this.extension.statusBar.activeModeSegment.setContent(mode.name);
editor.options.lineNumbers = mode.lineNumbers;
editor.options.cursorStyle = mode.cursorStyle;
return vscode.commands.executeCommand("setContext", extensionName + ".mode", mode.name);
}
/**
* Called when `vscode.window.onDidChangeActiveTextEditor` is triggered with
* another editor.
*
* @deprecated Do not call -- internal implementation detail.
*/
public notifyDidBecomeInactive(newEditorIsActive: boolean) {
if (!newEditorIsActive) {
this.extension.statusBar.activeModeSegment.setContent("<no active mode>");
return vscode.commands.executeCommand("setContext", extensionName + ".mode", undefined);
}
return Promise.resolve();
}
/**
* Called when `vscode.window.onDidChangeTextEditorSelection` is triggered on
* this editor.
*
* @deprecated Do not call -- internal implementation detail.
*/
public notifyDidChangeTextEditorSelection() {
this._updateDecorations(this._mode);
}
/**
* Called when the editor becomes visible again.
*
* @deprecated Do not call -- internal implementation detail.
*/
public notifyDidBecomeVisible(editor: vscode.TextEditor) {
assert(this._editor.document === editor.document);
this._editor = editor;
this._isVisible = true;
this._onVisibilityDidChange.fire(this);
}
/**
* Called when the editor was hidden, but not closed.
*
* @deprecated Do not call -- internal implementation detail.
*/
public notifyDidBecomeHidden() {
this._isVisible = false;
this._onVisibilityDidChange.fire(this);
}
// Updating decorations.
// =============================================================================================
private _clearDecorations(mode: Mode) {
const editor = this._editor,
empty = [] as never[];
for (const decoration of mode.decorations) {
editor.setDecorations(decoration.type, empty);
}
editor.setDecorations(this.extension.editors.characterDecorationType, empty);
}
private _updateDecorations(mode: Mode) {
const editor = this._editor,
allSelections = editor.selections;
for (const decoration of mode.decorations) {
const selections =
decoration.applyTo === "all"
? allSelections
: decoration.applyTo === "main"
? [allSelections[0]]
: allSelections.slice(1);
if (decoration.renderOptions.isWholeLine) {
const lines = selectionsLines(selections),
ranges: vscode.Range[] = [];
for (let i = 0, len = lines.length; i < len; i++) {
const startLine = lines[i];
let endLine = startLine;
while (i + 1 < lines.length && lines[i + 1] === endLine + 1) {
i++;
endLine++;
}
const start = new vscode.Position(startLine, 0),
end = startLine === endLine ? start : new vscode.Position(endLine, 0);
ranges.push(new vscode.Range(start, end));
}
editor.setDecorations(decoration.type, ranges);
} else {
editor.setDecorations(decoration.type, selections);
}
}
if (mode.selectionBehavior === SelectionBehavior.Character) {
const document = this._editor.document,
ranges = [] as vscode.Range[];
for (let i = 0; i < allSelections.length; i++) {
const selection = allSelections[i];
if (!selection.isEmpty) {
const end = Positions.next(selection.active, document);
if (end !== undefined) {
const active = selection.active,
start = active.character === 0 || active === selection.start
? active
: new vscode.Position(active.line, active.character - 1);
ranges.push(new vscode.Range(start, end));
}
}
}
editor.setDecorations(this.extension.editors.characterDecorationType, ranges);
} else {
editor.setDecorations(this.extension.editors.characterDecorationType, []);
}
editor.options.cursorStyle = mode.cursorStyle;
editor.options.lineNumbers = mode.lineNumbers;
}
private _updateSelectionsAfterBehaviorChange(mode: Mode) {
const editor = this._editor,
document = editor.document,
selections = editor.selections;
editor.selections = mode.selectionBehavior === SelectionBehavior.Character
? Selections.toCharacterMode(selections, document)
: Selections.fromCharacterMode(selections, document);
}
}
export namespace PerEditorState {
export declare class Token<T> {
private can_never_implement_this: never;
}
}
/**
* The set of all known editors, and their associated `PerEditorState`.
*/
export class Editors implements vscode.Disposable {
private readonly _editors = new Map<vscode.TextEditor, PerEditorState>();
private readonly _fallbacks = new Map<vscode.TextDocument, PerEditorState>();
private readonly _onModeDidChange = new vscode.EventEmitter<PerEditorState>();
private readonly _subscriptions: vscode.Disposable[] = [];
private _activeEditor?: PerEditorState;
/**
* @deprecated Do not access -- internal implementation detail.
*/
public readonly characterDecorationType = vscode.window.createTextEditorDecorationType({
backgroundColor: new vscode.ThemeColor("editor.selectionBackground"),
});
/**
* The Dance-specific state for the active `vscode.TextEditor`, or `undefined`
* if `vscode.window.activeTextEditor === undefined`.
*/
public get active() {
return this._activeEditor;
}
/**
* An event which fires on editor mode change.
*/
public readonly onModeDidChange = this._onModeDidChange.event;
public constructor(
private readonly _extension: Extension,
) {
vscode.window.onDidChangeActiveTextEditor(
this._handleDidChangeActiveTextEditor, this, this._subscriptions);
vscode.window.onDidChangeTextEditorSelection(
this._handleDidChangeTextEditorSelection, this, this._subscriptions);
vscode.window.onDidChangeVisibleTextEditors(
this._handleDidChangeVisibleTextEditors, this, this._subscriptions);
vscode.workspace.onDidCloseTextDocument(
this._handleDidCloseTextDocument, this, this._subscriptions);
process.nextTick(() => {
this._handleDidChangeVisibleTextEditors(vscode.window.visibleTextEditors);
const activeTextEditor = vscode.window.activeTextEditor;
if (activeTextEditor !== undefined) {
this._activeEditor = this._editors.get(activeTextEditor);
this._activeEditor?.notifyDidBecomeActive();
}
});
}
public dispose() {
this._subscriptions.splice(0).forEach((d) => d.dispose());
this.characterDecorationType.dispose();
}
/**
* Returns the Dance-specific state for the given `vscode.TextEditor`.
*/
public getState(editor: vscode.TextEditor) {
const state = this._editors.get(editor);
if (state === undefined) {
throw new Error(
"given editor does not have an equivalent EditorState; has it gone out of view?",
);
}
return state;
}
private _handleDidChangeActiveTextEditor(e: vscode.TextEditor | undefined) {
if (e === undefined) {
this._activeEditor?.notifyDidBecomeInactive(false);
this._activeEditor = undefined;
} else {
// Note that the call to `get` below requires that visible editors are
// updated via `onDidChangeVisibleTextEditors`. Thankfully
// `onDidChangeActiveTextEditor` is indeed triggered *after* that
// event.
this._activeEditor?.notifyDidBecomeInactive(true);
this._activeEditor = this._editors.get(e);
this._activeEditor?.notifyDidBecomeActive();
}
}
private _handleDidChangeTextEditorSelection(e: vscode.TextEditorSelectionChangeEvent) {
this._editors.get(e.textEditor)?.notifyDidChangeTextEditorSelection();
}
private _handleDidChangeVisibleTextEditors(visibleEditors: readonly vscode.TextEditor[]) {
const hiddenEditors = new Map(this._editors),
addedEditors = new Set<vscode.TextEditor>();
for (const visibleEditor of visibleEditors) {
if (!hiddenEditors.delete(visibleEditor)) {
// `visibleEditor` had not been previously added to `_editors`, so it's
// a new editor.
addedEditors.add(visibleEditor);
}
}
// Now `hiddenEditors` only contains editors that are no longer visible, and
// `addedEditors` only contains editors that were not visible previously.
const defaultMode = this._extension.modes.defaultMode;
for (const addedEditor of addedEditors) {
const fallback = this._fallbacks.get(addedEditor.document);
if (fallback !== undefined) {
fallback.notifyDidBecomeVisible(addedEditor);
this._editors.set(addedEditor, fallback);
this._fallbacks.delete(addedEditor.document);
} else {
this._editors.set(
addedEditor, new PerEditorState(this._extension, addedEditor, defaultMode));
}
}
// Dispose of no-longer-visible editors.
const addedFallbacks = new Set<vscode.TextDocument>();
for (const [hiddenEditor, state] of hiddenEditors) {
this._editors.delete(hiddenEditor);
// As soon as editors go out of view, their related `vscode.TextEditor`
// instances are made obsolete. However, we'd still like to keep state
// like active mode and selections in case the user reopens that editor
// shortly.
const fallback = this._fallbacks.get(hiddenEditor.document);
if (fallback === undefined) {
this._fallbacks.set(hiddenEditor.document, state);
addedFallbacks.add(hiddenEditor.document);
} else if (isMoreInteresting(fallback.editor, hiddenEditor)) {
fallback.dispose();
this._fallbacks.set(hiddenEditor.document, state);
addedFallbacks.add(hiddenEditor.document);
} else {
state.dispose();
}
}
for (const addedFallback of addedFallbacks) {
this._fallbacks.get(addedFallback)!.notifyDidBecomeHidden();
}
}
private _handleDidCloseTextDocument(document: vscode.TextDocument) {
// Dispose of fallback editor, if any.
const fallback = this._fallbacks.get(document);
if (fallback !== undefined) {
this._fallbacks.delete(document);
fallback.dispose();
} else {
// There is no fallback editor, so there might be visible editors related
// to that document.
for (const editor of vscode.window.visibleTextEditors) {
if (editor.document === document) {
const state = this._editors.get(editor);
if (state !== undefined) {
this._editors.delete(editor);
state.dispose();
}
}
}
}
}
/**
* @deprecated Do not call -- internal implementation detail.
*/
public notifyDidChangeMode(state: PerEditorState) {
this._onModeDidChange.fire(state);
}
}
function getTotalRange(editor: vscode.TextEditor) {
let minStart = editor.document.lineCount - 1,
maxEnd = 0;
for (const range of editor.visibleRanges) {
minStart = Math.min(range.start.line, minStart);
maxEnd = Math.max(range.end.line, maxEnd);
}
return maxEnd - minStart;
}
function isMoreInteresting(
currentEditor: vscode.TextEditor,
potentiallyMoreInteresting: vscode.TextEditor,
) {
// Compute the total range of each editor; the "most interesting editor" is
// the one with the greatest total range.
return getTotalRange(currentEditor) < getTotalRange(potentiallyMoreInteresting);
}

View File

@ -1,439 +1,181 @@
import * as vscode from "vscode";
import { DocumentState } from "./document";
import { EditorState } from "./editor";
import { commands } from "../commands";
import { extensionName } from "../extension";
import { Register, Registers } from "../registers";
// =============================================================================================
// == MODE-SPECIFIC CONFIGURATION ============================================================
// =============================================================================================
export const enum Mode {
Normal = "normal",
Insert = "insert",
Awaiting = "awaiting",
}
export const enum SelectionBehavior {
Caret = 1,
Character = 2,
}
export namespace ModeConfiguration {
export type CursorStyle =
| "line"
| "block"
| "underline"
| "line-thin"
| "block-outline"
| "underline-thin"
| "inherit";
export type LineNumbers = "on" | "off" | "relative" | "inherit";
}
export interface GotoMenuItem {
readonly text: string;
readonly command: string;
readonly args?: any[];
}
export interface GotoMenu {
readonly items: Record<string, GotoMenuItem>;
}
/**
* Mode-specific configuration.
*/
export class ModeConfiguration {
private constructor(
public readonly mode: Mode,
public readonly modePrefix: string,
public lineNumbers: vscode.TextEditorLineNumbersStyle,
public cursorStyle: vscode.TextEditorCursorStyle,
public decorationType?: vscode.TextEditorDecorationType,
) {}
public static insert() {
return new ModeConfiguration(
Mode.Insert,
"insertMode",
vscode.TextEditorLineNumbersStyle.On,
vscode.TextEditorCursorStyle.Line,
);
}
public static normal() {
return new ModeConfiguration(
Mode.Normal,
"normalMode",
vscode.TextEditorLineNumbersStyle.Relative,
vscode.TextEditorCursorStyle.Line,
);
}
public observeLineHighlightPreference(extension: Extension, defaultValue: string | null) {
extension.observePreference<string | null>(
this.modePrefix + ".lineHighlight",
defaultValue,
(value) => {
this.decorationType?.dispose();
if (value === null || value.length === 0) {
return (this.decorationType = undefined);
}
this.decorationType = vscode.window.createTextEditorDecorationType({
backgroundColor: value[0] === "#" ? value : new vscode.ThemeColor(value),
isWholeLine: true,
});
for (const editor of extension.editorStates()) {
if (editor.mode === this.mode && editor.isActive) {
editor.setDecorations(this.decorationType);
}
}
return;
},
true,
);
}
public observeLineNumbersPreference(
extension: Extension,
defaultValue: ModeConfiguration.LineNumbers,
) {
extension.observePreference<ModeConfiguration.LineNumbers>(
this.modePrefix + ".lineNumbers",
defaultValue,
(value) => {
this.lineNumbers = this.lineNumbersStringToLineNumbersStyle(value);
},
true,
);
}
public updateLineNumbers(extension: Extension, defaultValue: ModeConfiguration.LineNumbers) {
this.lineNumbers = this.lineNumbersStringToLineNumbersStyle(
extension.configuration.get(this.modePrefix + ".lineNumbers") ?? defaultValue,
);
}
public observeCursorStylePreference(
extension: Extension,
defaultValue: ModeConfiguration.CursorStyle,
) {
extension.observePreference<ModeConfiguration.CursorStyle>(
this.modePrefix + ".cursorStyle",
defaultValue,
(value) => {
this.cursorStyle = this.cursorStyleStringToCursorStyle(value);
},
true,
);
}
public updateCursorStyle(extension: Extension, defaultValue: ModeConfiguration.CursorStyle) {
this.cursorStyle = this.cursorStyleStringToCursorStyle(
extension.configuration.get(this.modePrefix + ".cursorStyle") ?? defaultValue,
);
}
private lineNumbersStringToLineNumbersStyle(lineNumbers: ModeConfiguration.LineNumbers) {
switch (lineNumbers) {
case "on":
return vscode.TextEditorLineNumbersStyle.On;
case "off":
return vscode.TextEditorLineNumbersStyle.Off;
case "relative":
return vscode.TextEditorLineNumbersStyle.Relative;
case "inherit":
default:
const vscodeLineNumbers = vscode.workspace
.getConfiguration()
.get<ModeConfiguration.LineNumbers | "interval">("editor.lineNumbers", "on");
switch (vscodeLineNumbers) {
case "on":
return vscode.TextEditorLineNumbersStyle.On;
case "off":
return vscode.TextEditorLineNumbersStyle.Off;
case "relative":
return vscode.TextEditorLineNumbersStyle.Relative;
case "interval": // This is a real option but its not in vscode.d.ts
return 3;
default:
return vscode.TextEditorLineNumbersStyle.On;
}
}
}
private cursorStyleStringToCursorStyle(cursorStyle: ModeConfiguration.CursorStyle) {
switch (cursorStyle) {
case "block":
return vscode.TextEditorCursorStyle.Block;
case "block-outline":
return vscode.TextEditorCursorStyle.BlockOutline;
case "line":
return vscode.TextEditorCursorStyle.Line;
case "line-thin":
return vscode.TextEditorCursorStyle.LineThin;
case "underline":
return vscode.TextEditorCursorStyle.Underline;
case "underline-thin":
return vscode.TextEditorCursorStyle.UnderlineThin;
case "inherit":
default:
const vscodeCursorStyle = vscode.workspace
.getConfiguration()
.get<ModeConfiguration.CursorStyle>("editor.cursorStyle", "line");
switch (vscodeCursorStyle) {
case "block":
return vscode.TextEditorCursorStyle.Block;
case "block-outline":
return vscode.TextEditorCursorStyle.BlockOutline;
case "line":
return vscode.TextEditorCursorStyle.Line;
case "line-thin":
return vscode.TextEditorCursorStyle.LineThin;
case "underline":
return vscode.TextEditorCursorStyle.Underline;
case "underline-thin":
return vscode.TextEditorCursorStyle.UnderlineThin;
default:
return vscode.TextEditorCursorStyle.Line;
}
}
}
}
import { Register, Registers } from "./registers";
import { assert, CancellationError, Menu, validateMenu } from "../api";
import { Modes } from "./modes";
import { SettingsValidator } from "../utils/settings-validator";
import { Recorder } from "./recorder";
import { Commands } from "../commands";
import { AutoDisposable } from "../utils/disposables";
import { StatusBar } from "./status-bar";
import { Editors } from "./editors";
// ===============================================================================================
// == EXTENSION ================================================================================
// ===============================================================================================
function showValidationError(message: string) {
vscode.window.showErrorMessage(`Configuration error: ${message}`);
}
/**
* Global state of the extension.
*/
export class Extension implements vscode.Disposable {
// Events.
private readonly configurationChangeHandlers = new Map<string, () => void>();
private readonly subscriptions: vscode.Disposable[] = [];
// Misc.
private readonly _configurationChangeHandlers = new Map<string, () => void>();
private readonly _subscriptions: vscode.Disposable[] = [];
// Configuration.
private readonly _gotoMenus = new Map<string, GotoMenu>();
private _selectionBehavior = SelectionBehavior.Caret;
// ==========================================================================
public configuration = vscode.workspace.getConfiguration(extensionName);
public get selectionBehavior() {
return this._selectionBehavior;
}
private readonly _gotoMenus = new Map<string, Menu>();
public get menus() {
return this._gotoMenus as ReadonlyMap<string, GotoMenu>;
return this._gotoMenus as ReadonlyMap<string, Menu>;
}
// General state.
public readonly statusBarItem: vscode.StatusBarItem;
public enabled: boolean = false;
// State.
// ==========================================================================
/**
* The `CancellationTokenSource` for cancellable operations running in this
* editor.
* `StatusBar` for this instance of the extension.
*/
public cancellationTokenSource?: vscode.CancellationTokenSource;
public readonly statusBar = new StatusBar();
/**
* `Registers` for this instance of the extension.
*/
public readonly registers = new Registers();
// Mode-specific configuration.
public readonly insertMode = ModeConfiguration.insert();
public readonly normalMode = ModeConfiguration.normal();
/**
* `Modes` for this instance of the extension.
*/
public readonly modes = new Modes(this);
public insertModeSelectionStyle?: vscode.TextEditorDecorationType;
/**
* `Recorder` for this instance of the extension.
*/
public readonly recorder = new Recorder(this.statusBar);
/**
* `Editors` for this instance of the extension.
*/
public readonly editors = new Editors(this);
// Ephemeral state needed by commands.
public currentCount: number = 0;
public currentRegister: Register | undefined = undefined;
// ==========================================================================
public constructor() {
this.statusBarItem = vscode.window.createStatusBarItem(undefined, 100);
this.statusBarItem.tooltip = "Current mode";
private _currentCount = 0;
private _currentRegister?: Register;
// This needs to be before setEnabled for normalizing selections on start.
this.observePreference<"caret" | "character">(
"selectionBehavior",
"caret",
(value) => {
this._selectionBehavior
= value === "caret" ? SelectionBehavior.Caret : SelectionBehavior.Character;
},
true,
);
/**
* The counter for the next command.
*/
public get currentCount() {
return this._currentCount;
}
// Configuration: line highlight.
this.insertMode.observeLineHighlightPreference(this, null);
this.normalMode.observeLineHighlightPreference(this, "editor.hoverHighlightBackground");
public set currentCount(count: number) {
this._currentCount = count;
// Configuration: line numbering.
this.insertMode.observeLineNumbersPreference(this, "inherit");
this.normalMode.observeLineNumbersPreference(this, "relative");
if (count !== 0) {
this.statusBar.countSegment.setContent(count.toString());
} else {
this.statusBar.countSegment.setContent();
}
}
this.configurationChangeHandlers.set("editor.lineNumbers", () => {
this.insertMode.updateLineNumbers(this, "inherit");
this.normalMode.updateLineNumbers(this, "relative");
});
/**
* The register to use in the next command.
*/
public get currentRegister() {
return this._currentRegister;
}
// Configuration: cursor style.
this.insertMode.observeCursorStylePreference(this, "inherit");
this.normalMode.observeCursorStylePreference(this, "inherit");
public set currentRegister(register: Register | undefined) {
this._currentRegister = register;
this.configurationChangeHandlers.set("editor.cursorStyle", () => {
this.insertMode.updateCursorStyle(this, "inherit");
this.normalMode.updateCursorStyle(this, "inherit");
});
// Configuration: selection style.
this.observePreference<Record<string, string | vscode.ThemeColor>>(
"insertMode.selectionStyle",
{},
(value) => {
if (typeof value !== "object" || value === null) {
return;
}
for (const key in value) {
const val = value[key];
if (typeof val !== "string") {
return;
}
if (val.startsWith("$")) {
value[key] = new vscode.ThemeColor(val.substr(1));
}
}
this.insertModeSelectionStyle?.dispose();
this.insertModeSelectionStyle = vscode.window.createTextEditorDecorationType(value);
},
true,
);
if (register !== undefined) {
this.statusBar.registerSegment.setContent(register.name);
} else {
this.statusBar.registerSegment.setContent();
}
}
public constructor(public readonly commands: Commands) {
// Configuration: menus.
this.observePreference<Record<string, { items: Record<string, GotoMenuItem | null> }>>(
"menus",
{},
(value) => {
this.observePreference<Record<string, Menu>>(
".menus",
(value, validator, inspect) => {
this._gotoMenus.clear();
if (typeof value !== "object" || value === null) {
return showValidationError(`"${extensionName}.menus" must be an object.`);
validator.reportInvalidSetting("must be an object");
return;
}
for (const menuName in value) {
const menu = value[menuName],
builtMenu: GotoMenu = { items: {} },
menuDisplay = `${extensionName}.menus[${JSON.stringify(menuName)}]`;
validationErrors = validateMenu(menu);
if (typeof menu !== "object" || menu === null) {
showValidationError(`menu ${menuDisplay} must be an object.`);
continue;
}
if (typeof menu.items !== "object" || Object.keys(menu.items ?? {}).length < 2) {
showValidationError(
`menu ${menuDisplay} must have an subobject "items" with at least two entries.`,
);
continue;
}
if (validationErrors.length === 0) {
const globalConfig = inspect.globalValue?.[menuName],
defaultConfig = inspect.defaultValue?.[menuName];
const seenKeyCodes = new Map<number, string>();
for (const key in menu.items) {
const item = menu.items[key],
itemDisplay = `${JSON.stringify(key)} of ${menuDisplay}`;
if (item === null) {
continue;
}
if (typeof item !== "object") {
showValidationError(`item ${itemDisplay} must be an object.`);
continue;
}
if (typeof item.text !== "string" || item.text.length === 0) {
showValidationError(`item ${itemDisplay} must have a non-empty "text" property.`);
continue;
}
if (typeof item.command !== "string" || item.command.length === 0) {
showValidationError(`item ${itemDisplay} must have a non-empty "command" property.`);
continue;
}
if (key.length === 0) {
showValidationError(`item ${itemDisplay} must be a non-empty string key.`);
continue;
}
let keyString = "";
for (let i = 0; i < key.length; i++) {
const keyCode = key.charCodeAt(i),
prevKey = seenKeyCodes.get(keyCode);
if (prevKey) {
showValidationError(`menu ${menuDisplay} has duplicate key '${key[i]}' `
+ `(specified by '${prevKey}' and '${key}').`);
continue;
if (globalConfig !== undefined || defaultConfig !== undefined) {
// Menu is a global menu; make sure that the local workspace does
// not override its items.
for (const key in menu.items) {
if (globalConfig !== undefined && key in globalConfig.items) {
menu.items[key] = globalConfig.items[key];
} else if (defaultConfig !== undefined && key in defaultConfig.items) {
menu.items[key] = defaultConfig.items[key];
}
}
seenKeyCodes.set(keyCode, key);
keyString = keyString === "" ? key[i] : `${keyString}, ${key[i]}`;
}
if (keyString.length === 0) {
continue;
this._gotoMenus.set(menuName, menu);
} else {
validator.enter(menuName);
for (const error of validationErrors) {
validator.reportInvalidSetting(error);
}
builtMenu.items[keyString] = {
command: item.command,
text: item.text,
args: item.args,
};
}
if (Object.keys(builtMenu.items).length > 0) {
this._gotoMenus.set(menuName, builtMenu);
validator.leave();
}
}
},
true,
);
// Lastly, enable the extension and set up modes.
this.setEnabled(this.configuration.get("enabled", true), false);
this._subscriptions.push(
// Update configuration automatically.
vscode.workspace.onDidChangeConfiguration((e) => {
for (const [section, handler] of this._configurationChangeHandlers.entries()) {
if (e.affectsConfiguration(section)) {
handler();
}
}
}),
);
// Register all commands.
for (const descriptor of Object.values(commands)) {
this._subscriptions.push(descriptor.register(this));
}
}
/**
* Disposes of the extension and all of its resources and subscriptions.
*/
public dispose() {
this.cancellationTokenSource?.cancel();
this.setEnabled(false, false);
this.statusBarItem.dispose();
this._cancellationTokenSource.cancel();
this._cancellationTokenSource.dispose();
this._autoDisposables.forEach((disposable) => disposable.dispose());
assert(this._autoDisposables.size === 0);
this.statusBar.dispose();
}
/**
@ -447,142 +189,191 @@ export class Extension implements vscode.Disposable {
*/
public observePreference<T>(
section: string,
defaultValue: T,
handler: (value: T) => void,
handler: (value: T, validator: SettingsValidator, inspect: InspectType<T>) => void,
triggerNow = false,
) {
this.configurationChangeHandlers.set("dance." + section, () => {
handler(this.configuration.get(section, defaultValue));
let configuration: vscode.WorkspaceConfiguration,
fullName: string;
if (section[0] === ".") {
fullName = extensionName + section;
section = section.slice(1);
configuration = vscode.workspace.getConfiguration(extensionName);
} else {
fullName = section;
configuration = vscode.workspace.getConfiguration();
}
const defaultValue = configuration.inspect<T>(section)!.defaultValue!;
this._configurationChangeHandlers.set(fullName, () => {
const validator = new SettingsValidator(fullName),
configuration = vscode.workspace.getConfiguration(extensionName);
handler(
configuration.get<T>(section, defaultValue),
validator,
handler.length > 2 ? configuration.inspect<T>(section)! : undefined!,
);
validator.displayErrorIfNeeded();
});
if (triggerNow) {
handler(this.configuration.get(section, defaultValue));
}
}
const validator = new SettingsValidator(fullName);
public setEnabled(enabled: boolean, changeConfiguration: boolean) {
if (enabled === this.enabled) {
return;
}
this.subscriptions.splice(0).forEach((x) => x.dispose());
if (!enabled) {
this.statusBarItem.hide();
for (const documentState of this.documentStates()) {
documentState.dispose();
}
this._documentStates = new Map();
if (changeConfiguration) {
vscode.workspace.getConfiguration(extensionName).update("enabled", false);
}
} else {
this.statusBarItem.show();
this.subscriptions.push(
vscode.window.onDidChangeActiveTextEditor((editor) => {
this._activeEditorState?.onDidBecomeInactive();
if (editor === undefined) {
this._activeEditorState = undefined;
} else {
this._activeEditorState = this.getEditorState(editor);
this._activeEditorState.onDidBecomeActive();
}
}),
vscode.window.onDidChangeTextEditorSelection((e) => {
this._documentStates
.get(e.textEditor.document)
?.getEditorState(e.textEditor)
?.onDidChangeTextEditorSelection(e);
}),
vscode.workspace.onDidChangeTextDocument((e) => {
this._documentStates.get(e.document)?.onDidChangeTextDocument(e);
}),
vscode.workspace.onDidChangeConfiguration((e) => {
this.configuration = vscode.workspace.getConfiguration(extensionName);
for (const [section, handler] of this.configurationChangeHandlers.entries()) {
if (e.affectsConfiguration(section)) {
handler();
}
}
}),
handler(
configuration.get(section, defaultValue),
validator,
handler.length > 2 ? configuration.inspect<T>(section)! : undefined!,
);
for (let i = 0; i < commands.length; i++) {
this.subscriptions.push(commands[i].register(this));
}
const activeEditor = vscode.window.activeTextEditor;
if (activeEditor !== undefined) {
this.getEditorState(activeEditor).onDidBecomeActive();
}
if (changeConfiguration) {
vscode.workspace.getConfiguration(extensionName).update("enabled", true);
}
validator.displayErrorIfNeeded();
}
return (this.enabled = enabled);
}
// =============================================================================================
// == DOCUMENT AND EDITOR STATES =============================================================
// == CANCELLATION ===========================================================================
// =============================================================================================
private _documentStates = new WeakMap<vscode.TextDocument, DocumentState>();
private _activeEditorState?: EditorState;
private _cancellationTokenSource = new vscode.CancellationTokenSource();
/**
* Returns the `DocumentState` for the given `vscode.TextDocument`.
* The token for the next command.
*/
public getDocumentState(document: vscode.TextDocument) {
let state = this._documentStates.get(document);
public get cancellationToken() {
return this._cancellationTokenSource.token;
}
if (state === undefined) {
this._documentStates.set(document, (state = new DocumentState(this, document)));
/**
* Requests the cancellation of the last operation.
*/
public cancelLastOperation() {
this._cancellationTokenSource.cancel();
this._cancellationTokenSource.dispose();
this._cancellationTokenSource = new vscode.CancellationTokenSource();
}
// =============================================================================================
// == DISPOSABLES ============================================================================
// =============================================================================================
private readonly _autoDisposables = new Set<AutoDisposable>();
/**
* Returns an `AutoDisposable` bound to this extension. It is ensured that any
* disposable added to it will be disposed of when the extension is unloaded.
*/
public createAutoDisposable() {
const disposable = new AutoDisposable();
disposable.addDisposable({
dispose: () => this._autoDisposables.delete(disposable),
});
this._autoDisposables.add(disposable);
return disposable;
}
// =============================================================================================
// == ERRORS =================================================================================
// =============================================================================================
private _dismissErrorMessage?: () => void;
/**
* Dismisses a currently shown error message, if any.
*/
public dismissErrorMessage() {
if (this._dismissErrorMessage !== undefined) {
this._dismissErrorMessage();
this._dismissErrorMessage = undefined;
}
}
/**
* Displays a dismissable error message in the status bar.
*/
public showDismissableErrorMessage(message: string) {
// Log the error so that long error messages and stacktraces can still be
// accessed by the user.
console.error(message);
if (message.length > 80) {
message = message.slice(0, 77) + "...";
}
return state;
if (this.statusBar.errorSegment.content !== undefined) {
return this.statusBar.errorSegment.setContent(message);
}
this.statusBar.errorSegment.setContent(message);
const dispose = () => {
this.statusBar.errorSegment.setContent();
this._dismissErrorMessage = undefined;
subscriptions.splice(0).forEach((d) => d.dispose());
};
const subscriptions = [
vscode.window.onDidChangeActiveTextEditor(dispose),
vscode.window.onDidChangeTextEditorSelection(dispose),
];
this._dismissErrorMessage = dispose;
}
/**
* Returns the `EditorState` for the given `vscode.TextEditor`.
* Runs the given function, displaying an error message and returning the
* specified value if it throws an exception during its execution.
*/
public getEditorState(editor: vscode.TextEditor) {
return this.getDocumentState(editor.document).getEditorState(editor);
}
public runSafely<T>(
f: () => T,
errorValue: () => T,
errorMessage: (error: any) => T extends Thenable<any> ? never : string,
) {
this.dismissErrorMessage();
/**
* Returns an iterator over all known `DocumentState`s.
*/
public *documentStates() {
const documents = vscode.workspace.textDocuments,
len = documents.length;
for (let i = 0; i < len; i++) {
const documentState = this._documentStates.get(documents[i]);
if (documentState !== undefined) {
yield documentState;
try {
return f();
} catch (e) {
if (!(e instanceof CancellationError)) {
this.showDismissableErrorMessage(errorMessage(e));
}
return errorValue();
}
}
/**
* Returns an iterator over all known `EditorState`s.
* Runs the given async function, displaying an error message and returning
* the specified value if it throws an exception during its execution.
*/
public *editorStates() {
for (const documentState of this.documentStates()) {
yield* documentState.editorStates();
public async runPromiseSafely<T>(
f: () => Thenable<T>,
errorValue: () => T,
errorMessage: (error: any) => string,
) {
this.dismissErrorMessage();
try {
return await f();
} catch (e) {
if (!(e instanceof CancellationError)) {
this.showDismissableErrorMessage(errorMessage(e));
}
return errorValue();
}
}
}
type InspectUnknown = Exclude<ReturnType<vscode.WorkspaceConfiguration["inspect"]>, undefined>;
type InspectType<T> = {
// Replace all properties that are `unknown` by `T | undefined`.
readonly [K in keyof InspectUnknown]: (InspectUnknown[K] & null) extends never
? InspectUnknown[K]
: T | undefined;
}

695
src/state/modes.ts Normal file
View File

@ -0,0 +1,695 @@
import * as vscode from "vscode";
import { command } from "../api";
import { extensionName } from "../extension";
import { Extension } from "./extension";
import { SettingsValidator } from "../utils/settings-validator";
import { workspaceSettingsPropertyNames } from "../utils/misc";
export const enum SelectionBehavior {
Caret = 1,
Character = 2,
}
/**
* An editing mode.
*/
export class Mode {
private readonly _onChanged = new vscode.EventEmitter<readonly [Mode, readonly (keyof Mode)[]]>();
private readonly _onDeleted = new vscode.EventEmitter<Mode>();
private _changeSubcription: vscode.Disposable | undefined;
private _raw: Mode.Configuration = {};
private _inheritsFrom: Mode;
private _cursorStyle = vscode.TextEditorCursorStyle.Line;
private _lineHighlight?: string | vscode.ThemeColor;
private _lineNumbers = vscode.TextEditorLineNumbersStyle.On;
private _selectionDecorationType?: vscode.TextEditorDecorationType;
private _selectionBehavior = SelectionBehavior.Caret;
private _onEnterMode: readonly command.Any[] = [];
private _onLeaveMode: readonly command.Any[] = [];
private _decorations: readonly Mode.Decoration[] = [];
public get onChanged() {
return this._onChanged.event;
}
public get onDeleted() {
return this._onDeleted.event;
}
public get inheritsFrom() {
return this._inheritsFrom;
}
public get cursorStyle() {
return this._cursorStyle;
}
public get lineNumbers() {
return this._lineNumbers;
}
public get lineHighlight() {
return this._lineHighlight;
}
public get selectionDecorationType() {
return this._selectionDecorationType;
}
public get selectionBehavior() {
return this._selectionBehavior;
}
public get onEnterMode() {
return this._onEnterMode;
}
public get onLeaveMode() {
return this._onLeaveMode;
}
public get decorations() {
return this._decorations;
}
public constructor(
public readonly modes: Modes,
public readonly name: string,
rawConfiguration: Mode.Configuration,
public isPendingDeletion = false,
) {
this._inheritsFrom = modes.vscodeMode;
this._raw = {};
if (rawConfiguration != null) {
this.apply(rawConfiguration, new SettingsValidator());
}
this._changeSubcription = this._inheritsFrom?.onChanged(this._onParentModeChanged, this);
}
/**
* Disposes of the mode.
*/
public dispose() {
this._changeSubcription?.dispose();
this._onDeleted.fire(this);
this._onChanged.dispose();
this._onDeleted.dispose();
}
private _onParentModeChanged([inheritFrom, keys]: readonly [Mode, readonly (keyof Mode)[]]) {
const updated = [] as (keyof Mode)[];
for (const key of keys) {
if (inheritFrom[key] !== this[key] && key !== "inheritsFrom") {
updated.push(key);
}
}
if (updated.length > 0) {
this._onChanged.fire([this, updated]);
}
}
/**
* Updates an underlying value of the mode.
*/
public update<K extends string & keyof this>(key: `_${K}`, value: this[K]) {
if (this[key as keyof this] === value) {
return;
}
this[key as keyof this] = value;
this._onChanged.fire([this, [key.slice(1) as keyof Mode]]);
}
/**
* Applies a new configuration to the mode, notifying subscribers of changes
* if needed.
*/
public apply(raw: Mode.Configuration, validator: SettingsValidator) {
const willInheritFrom = raw.inheritFrom == null
? this.modes.vscodeMode
: this.modes.getOrCreateDummy(raw.inheritFrom);
const changedProperties: (keyof Mode)[] = [];
if (willInheritFrom !== this._inheritsFrom) {
this._changeSubcription?.dispose();
this._inheritsFrom = willInheritFrom;
this._changeSubcription = willInheritFrom.onChanged(this._onParentModeChanged, this);
changedProperties.push("inheritsFrom");
}
const up = willInheritFrom,
top = this.modes.vscodeMode,
map = <RN extends keyof Mode.Configuration, N extends keyof Mode, C>(
rawName: RN,
name: N,
convert: (value: Exclude<Mode.Configuration[RN], null | undefined>,
validator: SettingsValidator) => C,
) => {
const value = raw[rawName];
if (value === undefined || value === "inherit") {
// Unspecified: use parent value.
return up[name];
}
if (value === null) {
// Null: use VS Code value.
return top[name];
}
return validator.forProperty(rawName, (validator) => convert(value as any, validator));
};
// Cursor style.
const cursorStyle = map("cursorStyle", "cursorStyle", Mode.cursorStyleStringToCursorStyle);
if (this._cursorStyle !== cursorStyle) {
this._cursorStyle = cursorStyle;
changedProperties.push("cursorStyle");
}
// Line numbers.
const lineNumbers = map("lineNumbers", "lineNumbers", Mode.lineNumbersStringToLineNumbersStyle);
if (this._lineNumbers !== lineNumbers) {
this._lineNumbers = lineNumbers;
changedProperties.push("lineNumbers");
}
// Selection behavior.
const selectionBehavior = map("selectionBehavior", "selectionBehavior",
Mode.selectionBehaviorStringToSelectionBehavior);
if (this._selectionBehavior !== selectionBehavior) {
this._selectionBehavior = selectionBehavior;
changedProperties.push("selectionBehavior");
}
// Selection decorations.
const disposePreviousDecorations = this._raw?.decorations != null;
let decorations = raw.decorations;
if (decorations === undefined) {
if (this._raw.decorations !== undefined) {
if (disposePreviousDecorations) {
this._decorations.forEach((d) => d.type.dispose());
}
this._decorations = up._decorations;
changedProperties.push("decorations");
}
} else if (decorations === null) {
if (this._raw.decorations !== null) {
if (disposePreviousDecorations) {
this._decorations.forEach((d) => d.type.dispose());
}
this._decorations = top._decorations;
changedProperties.push("decorations");
}
} else if (JSON.stringify(decorations) !== JSON.stringify(this._raw?.decorations)) {
if (!Array.isArray(decorations)) {
decorations = [decorations as Mode.Configuration.Decoration];
}
if (disposePreviousDecorations) {
this._decorations.forEach((d) => d.type.dispose());
}
validator.enter("decorations");
this._decorations = decorations.flatMap((d) => {
const validatorErrors = validator.errors.length,
renderOptions = Mode.decorationObjectToDecorationRenderOptions(d, validator),
applyTo = Mode.applyToStringToApplyTo(d.applyTo ?? "all", validator);
if (validator.errors.length > validatorErrors) {
return [];
}
return [{
applyTo,
renderOptions,
type: vscode.window.createTextEditorDecorationType(renderOptions),
}];
});
validator.leave();
changedProperties.push("decorations");
}
// Events (subscribers don't care about changes to these properties, so we
// don't add them to the `changedProperties`).
this._onEnterMode = raw.onEnterMode ?? [];
this._onLeaveMode = raw.onLeaveMode ?? [];
// Save raw JSON for future reference and notify subscribers of changes.
this._raw = raw;
if (changedProperties.length > 0) {
this._onChanged.fire([this, changedProperties]);
}
}
/**
* Validates and converts a string to a `vscode.TextEditorLineNumbersStyle`
* enum value.
*/
public static lineNumbersStringToLineNumbersStyle(
lineNumbers: Mode.Configuration.LineNumbers,
validator: SettingsValidator,
) {
switch (lineNumbers) {
case "on":
return vscode.TextEditorLineNumbersStyle.On;
case "off":
return vscode.TextEditorLineNumbersStyle.Off;
case "relative":
return vscode.TextEditorLineNumbersStyle.Relative;
default:
validator.reportInvalidSetting(`unrecognized lineNumbers "${lineNumbers}"`, "lineNumbers");
return vscode.TextEditorLineNumbersStyle.On;
}
}
/**
* Validates and converts a string to a `vscode.TextEditorCursorStyle` enum
* value.
*/
public static cursorStyleStringToCursorStyle(
cursorStyle: Mode.Configuration.CursorStyle,
validator: SettingsValidator,
) {
switch (cursorStyle) {
case "block":
return vscode.TextEditorCursorStyle.Block;
case "block-outline":
return vscode.TextEditorCursorStyle.BlockOutline;
case "line":
return vscode.TextEditorCursorStyle.Line;
case "line-thin":
return vscode.TextEditorCursorStyle.LineThin;
case "underline":
return vscode.TextEditorCursorStyle.Underline;
case "underline-thin":
return vscode.TextEditorCursorStyle.UnderlineThin;
default:
validator.reportInvalidSetting(`unrecognized cursorStyle "${cursorStyle}"`, "cursorStyle");
return vscode.TextEditorCursorStyle.Line;
}
}
/**
* Validates and converts a string to a `SelectionBehavior` enum value.
*/
public static selectionBehaviorStringToSelectionBehavior(
behavior: Mode.Configuration.SelectionBehavior,
validator: SettingsValidator,
) {
switch (behavior) {
case "character":
return SelectionBehavior.Character;
case "caret":
return SelectionBehavior.Caret;
default:
validator.reportInvalidSetting(
`unrecognized selectionBehavior "${behavior}"`,
"selectionBehavior",
);
return SelectionBehavior.Caret;
}
}
/**
* Validates and converts a configuration decoration to an actual
* `vscode.DecorationRenderOptions` object.
*/
public static decorationObjectToDecorationRenderOptions(
object: Mode.Configuration.Decoration,
validator: SettingsValidator,
) {
const options: vscode.DecorationRenderOptions = {};
for (const name of ["backgroundColor", "borderColor"] as const) {
const value = object[name];
if (value) {
validator.forProperty(name, (v) => options[name] = this.stringToColor(value, v, "#000"));
}
}
for (const name of ["borderStyle"] as const) {
const value = object[name];
if (value) {
options[name] = value;
}
}
for (const name of ["isWholeLine"] as const) {
const value = object[name];
if (value != null) {
options[name] = !!object[name];
}
}
for (const name of ["borderRadius", "borderWidth"] as const) {
const value = object[name];
if (value) {
options[name] = value;
}
}
return options;
}
/**
* Validates and converts a string value to a valid `applyTo` value.
*/
public static applyToStringToApplyTo(value: string, validator: SettingsValidator) {
const applyTo = value;
if (!["all", "main", "secondary"].includes(applyTo)) {
validator.reportInvalidSetting(`unrecognized applyTo ${JSON.stringify(applyTo)}`,
"applyTo");
return "all";
}
return applyTo as "all" | "main" | "secondary";
}
/**
* Validates and converts a string value to a string color or
* `vscode.ThemeColor`.
*/
public static stringToColor(value: string, validator: SettingsValidator, invalidValue = "") {
if (typeof value !== "string" || value.length === 0) {
validator.reportInvalidSetting("color must be a non-empty string");
return invalidValue;
}
if (value[0] === "$") {
if (/^\$[\w]+(\.\w+)*$/.test(value)) {
return new vscode.ThemeColor(value.slice(1));
}
validator.reportInvalidSetting("invalid color reference " + value);
return invalidValue;
}
if (value[0] === "#") {
if (/^#([a-fA-F0-9]{3}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/.test(value)) {
return value;
}
validator.reportInvalidSetting("invalid color " + value);
return invalidValue;
}
if (value.startsWith("rgb")) {
if (/^rgb\( *\d+ *, *\d+ *, *\d+ *\)$|^rgba\( *\d+ *, *\d+ *, *\d+ *, *\d+ *\)$/.test(value)) {
return value;
}
validator.reportInvalidSetting("invalid color " + value);
return invalidValue;
}
validator.reportInvalidSetting("unknown color format " + value);
return invalidValue;
}
}
export namespace Mode {
/**
* The configuration of a `Mode` as specified in the user preferences.
*/
export interface Configuration {
cursorStyle?: Configuration.CursorStyle;
decorations?: Configuration.Decoration[] | Configuration.Decoration;
inheritFrom?: string | null;
lineHighlight?: string | null;
lineNumbers?: Configuration.LineNumbers;
onEnterMode?: readonly command.Any[];
onLeaveMode?: readonly command.Any[];
selectionBehavior?: Configuration.SelectionBehavior | null;
}
/**
* A mode decoration.
*/
export interface Decoration {
readonly applyTo: "all" | "main" | "secondary";
readonly renderOptions: vscode.DecorationRenderOptions;
readonly type: vscode.TextEditorDecorationType;
}
export namespace Configuration {
/**
* A valid cursor style value in a `Mode.Configuration`.
*/
export type CursorStyle =
| "line"
| "block"
| "underline"
| "line-thin"
| "block-outline"
| "underline-thin";
/**
* A valid line numbers value in a `Mode.Configuration`.
*/
export type LineNumbers = "on" | "off" | "relative";
/**
* A valid selection behavior value in a `Mode.Configuration`.
*/
export type SelectionBehavior = "caret" | "character";
/**
* A decoration.
*/
export interface Decoration {
readonly applyTo?: "all" | "main" | "secondary";
readonly backgroundColor?: string;
readonly borderColor?: string;
readonly borderStyle?: string;
readonly borderRadius?: string;
readonly borderWidth?: string;
readonly isWholeLine?: boolean;
}
}
}
/**
* The set of all modes.
*/
export class Modes implements Iterable<Mode> {
private readonly _modes = new Map<string, Mode>();
private readonly _vscodeMode = new Mode(this, "", undefined!);
private readonly _inputMode = new Mode(this, "input", { cursorStyle: "underline-thin" });
private _defaultMode = new Mode(this, "default", {});
public constructor(extension: Extension) {
for (const builtin of [this._defaultMode, this._inputMode]) {
this._modes.set(builtin.name, builtin);
}
this._vscodeMode.apply({
cursorStyle: "line",
inheritFrom: null,
lineHighlight: null,
lineNumbers: "on",
selectionBehavior: "caret",
decorations: [],
}, new SettingsValidator());
this._observePreferences(extension);
}
/**
* The default mode configured using `dance.defaultMode`.
*/
public get defaultMode() {
return this._defaultMode;
}
/**
* The input mode, set when awaiting user input.
*/
public get inputMode() {
return this._inputMode;
}
/**
* The "VS Code" mode, which represents the settings assigned to the editor
* without taking Dance settings into account.
*/
public get vscodeMode() {
return this._vscodeMode;
}
/**
* Returns the `Mode` with the given name, or `undefined` if no such mode is
* defined.
*/
public get(name: string) {
return this._modes.get(name);
}
/**
* Returns the `Mode` with the given name, or creates one if no such mode is
* defined.
*/
public getOrCreateDummy(name: string) {
let mode = this._modes.get(name);
if (mode === undefined) {
this._modes.set(name, mode = new Mode(this, name, {}, /* isPendingDeletion= */ true));
}
return mode;
}
public [Symbol.iterator]() {
return this._modes.values();
}
public *userModes() {
for (const mode of this._modes.values()) {
if (mode.name !== "input" && !mode.isPendingDeletion) {
yield mode;
}
}
}
/**
* Starts listening to changes in user preferences that may lead to updates to
* user modes.
*/
private _observePreferences(extension: Extension) {
// Mode definitions.
extension.observePreference<Modes.Configuration>(".modes", (value, validator, inspect) => {
let isEmpty = true;
const removeModes = new Set(this._modes.keys()),
expectedDefaultModeName = vscode.workspace.getConfiguration(extensionName)
.get<string>("defaultMode");
removeModes.delete(this.inputMode.name);
for (const modeName in value) {
removeModes.delete(modeName);
if (modeName === "input" || modeName === "") {
validator.reportInvalidSetting(`a mode cannot be named "${modeName}"`);
continue;
}
let mode = this._modes.get(modeName);
const configuration = value[modeName];
const globalConfig = inspect.globalValue?.[modeName],
defaultConfig = inspect.defaultValue?.[modeName];
if (globalConfig !== undefined || defaultConfig !== undefined) {
// Mode is a global mode; make sure that the local workspace does not
// override its `on{Enter,Leave}Mode` hooks (to make sure loading a
// workspace will not execute arbitrary code).
configuration.onEnterMode = globalConfig?.onEnterMode ?? defaultConfig?.onEnterMode;
configuration.onLeaveMode = globalConfig?.onLeaveMode ?? defaultConfig?.onLeaveMode;
}
if (mode === undefined) {
this._modes.set(modeName, mode = new Mode(this, modeName, configuration));
if (modeName === expectedDefaultModeName) {
this._defaultMode.dispose();
this._defaultMode = mode;
}
} else {
mode.isPendingDeletion = false;
mode.apply(configuration, validator);
}
isEmpty = false;
}
if (isEmpty) {
validator.reportInvalidSetting("at least one mode must be defined");
}
const actualDefaultModeName = this._defaultMode.name;
for (const modeName of removeModes) {
if (modeName === actualDefaultModeName) {
validator.reportInvalidSetting(
"default mode was removed, please update dance.defaultMode",
);
} else {
this._modes.get(modeName)!.dispose();
}
this._modes.delete(modeName);
}
}, true);
// Default mode.
extension.observePreference<string>(".defaultMode", (value, validator) => {
const mode = this._modes.get(value);
if (mode === undefined) {
return validator.reportInvalidSetting("mode does not exist: " + value);
}
if (!this._modes.has(this._defaultMode.name)) {
// Default mode had previously been deleted; we can now dispose of it.
this._defaultMode.dispose();
}
this._defaultMode = mode;
}, true);
// VS Code settings.
extension.observePreference<Mode.Configuration.CursorStyle>(
"editor.cursorStyle",
(value, validator) => {
this._vscodeMode.update(
"_cursorStyle",
Mode.cursorStyleStringToCursorStyle(value, validator),
);
},
true,
);
extension.observePreference<Mode.Configuration.LineNumbers>(
"editor.lineNumbers",
(value, validator) => {
this._vscodeMode.update(
"_lineNumbers",
Mode.lineNumbersStringToLineNumbersStyle(value, validator),
);
},
true,
);
}
}
export namespace Modes {
export interface Configuration {
readonly [modeName: string]: Mode.Configuration;
}
}

923
src/state/recorder.ts Normal file
View File

@ -0,0 +1,923 @@
import * as vscode from "vscode";
import { CommandDescriptor } from "../commands";
import { noUndoStops } from "../utils/misc";
import { StatusBar } from "./status-bar";
import { Context } from "../api/context";
import { assert, CancellationError, EditorRequiredError, todo } from "../api/errors";
import { Positions } from "../api/positions";
type RecordValue = Recording.ActionType | CommandDescriptor | object | vscode.Uri | number | string;
const enum Constants {
NextMask = 0xff,
PrevShift = 8,
}
/**
* A class used to record actions as they happen.
*/
export class Recorder implements vscode.Disposable {
private readonly _previousBuffers: Recorder.Buffer[] = [];
private readonly _subscriptions: vscode.Disposable[] = [];
private _activeDocument: vscode.TextDocument | undefined;
private _buffer: RecordValue[] = [0];
private _lastActiveSelections: readonly vscode.Selection[] | undefined;
private _activeRecordingTokens: vscode.Disposable[] = [];
public constructor(
private readonly _statusBar: StatusBar,
) {
const activeEditor = vscode.window.activeTextEditor;
if (activeEditor !== undefined) {
this._activeDocument = activeEditor.document;
this._lastActiveSelections = activeEditor.selections;
}
this._subscriptions.push(
vscode.window.onDidChangeActiveTextEditor(this._recordActiveTextEditorChange, this),
vscode.window.onDidChangeTextEditorSelection(this._recordExternalSelectionChange, this),
vscode.workspace.onDidChangeTextDocument(this._recordExternalTextChange, this),
);
}
public dispose() {
this._activeRecordingTokens.splice(0).forEach((d) => d.dispose());
this._subscriptions.splice(0).forEach((d) => d.dispose());
}
/**
* Starts a record, saving its identifier to the current buffer.
*/
private _startRecord(type: Recording.ActionType) {
(this._buffer[this._buffer.length - 1] as Recording.ActionType) |= type;
}
/**
* Ends a record, saving its identifier to the current buffer.
*/
private _endRecord(type: Recording.ActionType) {
this._buffer.push(type << Constants.PrevShift);
this._archiveBufferIfNeeded();
}
/**
* Archives the current buffer to `_previousBuffers` if its size exceeded a
* threshold and if no recording is currently ongoing.
*/
private _archiveBufferIfNeeded() {
if (this._activeRecordingTokens.length > 0 || this._buffer.length < 8192) {
return;
}
this._previousBuffers.push(this._buffer);
this._buffer = [];
}
/**
* Returns the number of available buffers.
*/
public get bufferCount() {
return this._previousBuffers.length + 1;
}
/**
* Returns the buffer at the given index, if any.
*/
public getBuffer(index: number) {
return index === this._previousBuffers.length ? this._buffer : this._previousBuffers[index];
}
/**
* Returns a `Cursor` starting at the start of the recorder.
*/
public cursorFromStart() {
return new Recorder.Cursor(this, 0, 0);
}
/**
* Returns a `Cursor` starting at the end of the recorder at the time of the
* call.
*/
public cursorFromEnd() {
return new Recorder.Cursor(this, this._previousBuffers.length, this._buffer.length - 1);
}
/**
* Returns a `Cursor` starting at the start of the specified recording.
*/
public fromRecordingStart(recording: Recording) {
let bufferIdx = this._previousBuffers.indexOf(recording.buffer);
if (bufferIdx === -1) {
assert(recording.buffer === this._buffer);
bufferIdx = this._previousBuffers.length;
}
return new Recorder.Cursor(this, bufferIdx, recording.offset);
}
/**
* Returns a `Cursor` starting at the end of the specified recording.
*/
public fromRecordingEnd(recording: Recording) {
let bufferIdx = this._previousBuffers.indexOf(recording.buffer);
if (bufferIdx === -1) {
assert(recording.buffer === this._buffer);
bufferIdx = this._previousBuffers.length;
}
return new Recorder.Cursor(this, bufferIdx, recording.offset + recording.length);
}
/**
* Starts recording a series of actions.
*/
public startRecording() {
const onRecordingCompleted = () => {
const index = this._activeRecordingTokens.indexOf(cancellationTokenSource);
if (index === -1) {
throw new Error("recording has already been marked as completed");
}
this._activeRecordingTokens.splice(index, 1);
cancellationTokenSource.dispose();
const activeRecordingsCount = this._activeRecordingTokens.length;
if (activeRecordingsCount === 0) {
this._statusBar.recordingSegment.setContent();
vscode.commands.executeCommand("setContext", "dance.isRecording", false);
} else {
this._statusBar.recordingSegment.setContent("" + activeRecordingsCount);
}
const buffer = this._buffer;
this._archiveBufferIfNeeded();
return new Recording(buffer, offset, buffer.length - offset);
};
const offset = this._buffer.length - 1,
cancellationTokenSource = new vscode.CancellationTokenSource(),
recording = new ActiveRecording(onRecordingCompleted, cancellationTokenSource.token),
activeRecordingsCount = this._activeRecordingTokens.push(cancellationTokenSource);
this._statusBar.recordingSegment.setContent("" + activeRecordingsCount);
if (activeRecordingsCount === 1) {
vscode.commands.executeCommand("setContext", "dance.isRecording", true);
}
return recording;
}
/**
* Replays the action at the given index, and returns the index of the next
* action. This index may be equal to the `length` of the buffer.
*/
public replay(buffer: Recorder.Buffer, index: number, context: Context.WithoutActiveEditor) {
switch ((buffer[index] as number) & Constants.NextMask) {
case Recording.ActionType.Break:
return Promise.resolve(index + 1);
case Recording.ActionType.Command:
return this.replayCommand(buffer, index, context);
case Recording.ActionType.ExternalCommand:
return this.replayExternalCommand(buffer, index);
case Recording.ActionType.TextEditorChange:
return this.replayTextEditorChange(buffer, index);
case Recording.ActionType.SelectionTranslation:
return Promise.resolve(this.replaySelectionTranslation(buffer, index));
case Recording.ActionType.SelectionTranslationToLineEnd:
todo();
break;
case Recording.ActionType.TextReplacement:
return this.replayTextReplacement(buffer, index);
default:
throw new Error("invalid recorder buffer given");
}
}
/**
* Returns the record at the given index in the given buffer.
*/
public readRecord(buffer: Recorder.Buffer, index: number) {
switch ((buffer[index] as number) & Constants.NextMask) {
case Recording.ActionType.Break:
return buffer.slice(index, index + 1) as Recording.Entry<Recording.ActionType.Break>;
case Recording.ActionType.Command:
return buffer.slice(index, index + 3) as Recording.Entry<Recording.ActionType.Command>;
case Recording.ActionType.ExternalCommand:
return buffer.slice(index, index + 3) as
Recording.Entry<Recording.ActionType.ExternalCommand>;
case Recording.ActionType.TextEditorChange:
return buffer.slice(index, index + 2) as
Recording.Entry<Recording.ActionType.TextEditorChange>;
case Recording.ActionType.SelectionTranslation:
return buffer.slice(index, index + 3) as
Recording.Entry<Recording.ActionType.SelectionTranslation>;
case Recording.ActionType.SelectionTranslationToLineEnd:
todo();
break;
case Recording.ActionType.TextReplacement:
return buffer.slice(index, index + 3) as
Recording.Entry<Recording.ActionType.TextReplacement>;
default:
throw new Error("invalid recorder buffer given");
}
}
/**
* Records the invocation of a command.
*/
public recordCommand(descriptor: CommandDescriptor, argument: Record<string, any>) {
this._startRecord(Recording.ActionType.Command);
this._buffer.push(descriptor, argument);
this._endRecord(Recording.ActionType.Command);
}
/**
* Replays the command at the given index, and returns the index of the next
* record in the given buffer.
*/
public async replayCommand(
buffer: Recorder.Buffer,
index: number,
context = Context.WithoutActiveEditor.current,
) {
assert(((buffer[index] as number) & Constants.NextMask) === Recording.ActionType.Command);
const descriptor = buffer[index + 1] as CommandDescriptor,
argument = buffer[index + 2] as object;
if ((descriptor.flags & CommandDescriptor.Flags.DoNotReplay) === 0) {
await descriptor.replay(context, argument);
}
return index + 3;
}
/**
* Records the invocation of an external (non-Dance) command.
*/
public recordExternalCommand(identifier: string, argument: Record<string, any>) {
this._startRecord(Recording.ActionType.ExternalCommand);
this._buffer.push(identifier, argument);
this._endRecord(Recording.ActionType.ExternalCommand);
}
/**
* Replays the command at the given index, and returns the index of the next
* record in the given buffer.
*/
public replayExternalCommand(buffer: Recorder.Buffer, index: number) {
assert(
((buffer[index] as number) & Constants.NextMask) === Recording.ActionType.ExternalCommand);
const descriptor = buffer[index + 1] as string,
argument = buffer[index + 2] as object;
return vscode.commands.executeCommand(descriptor, argument).then(() => index + 3);
}
/**
* Records a change in the active text editor.
*/
private _recordActiveTextEditorChange(e: vscode.TextEditor | undefined) {
if (e?.document !== this._activeDocument) {
if (e?.document === undefined) {
this._activeDocument = undefined;
this._lastActiveSelections = undefined;
this._recordBreak();
} else {
this._activeDocument = e.document;
this._lastActiveSelections = e.selections;
this._startRecord(Recording.ActionType.TextEditorChange);
this._buffer.push(e.document.uri);
this._endRecord(Recording.ActionType.TextEditorChange);
}
}
}
/**
* Replays a text editor change, and returns the index of the next record in
* the given buffer.
*/
public replayTextEditorChange(buffer: Recorder.Buffer, index: number) {
assert(
((buffer[index] as number) & Constants.NextMask) === Recording.ActionType.TextEditorChange);
const documentUri = buffer[index + 1] as vscode.Uri;
return vscode.window.showTextDocument(documentUri).then(() => index + 2);
}
/**
* Records a change of a selection.
*/
private _recordExternalSelectionChange(e: vscode.TextEditorSelectionChangeEvent) {
if (vscode.window.activeTextEditor !== e.textEditor) {
return;
}
const lastSelections = this._lastActiveSelections,
selections = e.selections;
this._lastActiveSelections = selections;
// Issue: Command is executing but not in a context, so we log selection changes which is bad
if (Context.WithoutActiveEditor.currentOrUndefined !== undefined
|| lastSelections === undefined) {
return;
}
if (lastSelections.length !== selections.length
|| e.kind === vscode.TextEditorSelectionChangeKind.Mouse) {
return this._recordBreak();
}
// TODO: be able to record jumps to end of line with a line offset
const document = e.textEditor.document;
let commonAnchorOffsetDiff = Number.MAX_SAFE_INTEGER,
commonActiveOffsetDiff = Number.MAX_SAFE_INTEGER;
for (let i = 0, len = selections.length; i < len; i++) {
const lastSelection = lastSelections[i],
selection = selections[i];
const lastAnchorOffset = document.offsetAt(lastSelection.active),
anchorOffset = document.offsetAt(selection.active),
anchorOffsetDiff = anchorOffset - lastAnchorOffset;
if (commonAnchorOffsetDiff === Number.MAX_SAFE_INTEGER) {
commonAnchorOffsetDiff = anchorOffsetDiff;
} else if (commonAnchorOffsetDiff !== anchorOffsetDiff) {
return this._tryRecordSelectionTranslationToLineEnd();
}
const lastActiveOffset = document.offsetAt(lastSelection.active),
activeOffset = document.offsetAt(selection.active),
activeOffsetDiff = activeOffset - lastActiveOffset;
if (commonActiveOffsetDiff === Number.MAX_SAFE_INTEGER) {
commonActiveOffsetDiff = activeOffsetDiff;
} else if (commonActiveOffsetDiff !== activeOffsetDiff) {
return this._tryRecordSelectionTranslationToLineEnd();
}
}
// Merge consecutive events, if any.
if (this._tryMergeSelectionTranslations(commonAnchorOffsetDiff, commonActiveOffsetDiff)) {
return;
}
this._startRecord(Recording.ActionType.SelectionTranslation);
this._buffer.push(commonAnchorOffsetDiff, commonActiveOffsetDiff);
this._endRecord(Recording.ActionType.SelectionTranslation);
}
private _tryMergeSelectionTranslations(anchorOffsetDiff2: number, activeOffsetDiff2: number) {
if (anchorOffsetDiff2 !== activeOffsetDiff2) {
return false;
}
const cursor = this.cursorFromEnd();
// "Text change 1 -> selection change 1 -> text change 2 -> selection
// change 2" can be merged into "text change 3 -> selection change 3".
if (!cursor.previous()
|| !cursor.is(Recording.ActionType.TextReplacement)
|| cursor.offsetFromActive() !== 0) {
return false;
}
const insertedText2 = cursor.insertedText(),
deletionLength2 = cursor.deletionLength();
if (insertedText2.length !== activeOffsetDiff2 || deletionLength2 !== 0) {
return false;
}
if (!cursor.previous() || !cursor.is(Recording.ActionType.SelectionTranslation)) {
return false;
}
const anchorOffsetDiff1 = cursor.anchorOffsetDiff(),
activeOffsetDiff1 = cursor.activeOffsetDiff();
if (anchorOffsetDiff1 !== activeOffsetDiff1) {
return false;
}
if (!cursor.previous()
|| !cursor.is(Recording.ActionType.TextReplacement)
|| cursor.offsetFromActive() !== 0) {
return false;
}
const insertedText1 = cursor.insertedText();
if (insertedText1.length !== activeOffsetDiff1) {
return false;
}
// This is a match! Update "text change 1 -> selection change 1".
(cursor.buffer as Recorder.MutableBuffer)[cursor.offset + 1] = insertedText1 + insertedText2;
assert(cursor.next() && cursor.is(Recording.ActionType.SelectionTranslation));
const totalDiff = anchorOffsetDiff1 + anchorOffsetDiff2;
(cursor.buffer as Recorder.MutableBuffer)[cursor.offset + 1] = totalDiff;
(cursor.buffer as Recorder.MutableBuffer)[cursor.offset + 2] = totalDiff;
// Finally, delete the last entry corresponding to "text change 2" (since
// "selection change 2" hasn't been written yet).
assert(cursor.next() && cursor.is(Recording.ActionType.TextReplacement));
(cursor.buffer as Recorder.MutableBuffer).splice(cursor.offset);
this._endRecord(Recording.ActionType.SelectionTranslation);
return true;
}
private _tryRecordSelectionTranslationToLineEnd() {
// TODO
return this._recordBreak();
}
/**
* Replays the selection translation at the given index, and returns the index
* of the next record in the given buffer.
*/
public replaySelectionTranslation(buffer: Recorder.Buffer, index: number) {
assert(((buffer[index] as number) & Constants.NextMask)
=== Recording.ActionType.SelectionTranslation);
const anchorOffsetDiff = buffer[index + 1] as number,
activeOffsetDiff = buffer[index + 2] as number;
const editor = vscode.window.activeTextEditor;
EditorRequiredError.throwUnlessAvailable(editor);
const document = editor.document,
newSelections = [] as vscode.Selection[];
for (const selection of editor.selections) {
const newAnchor = Positions.offset.orEdge(selection.anchor, anchorOffsetDiff, document),
newActive = Positions.offset.orEdge(selection.active, activeOffsetDiff, document);
newSelections.push(new vscode.Selection(newAnchor, newActive));
}
editor.selections = newSelections;
return index + 3;
}
/**
* Records a change to a document.
*/
private _recordExternalTextChange(e: vscode.TextDocumentChangeEvent) {
const editor = vscode.window.activeTextEditor;
if (editor?.document !== e.document) {
return;
}
const lastSelections = this._lastActiveSelections,
selections = editor.selections;
this._lastActiveSelections = selections;
if (Context.WithoutActiveEditor.currentOrUndefined !== undefined
|| lastSelections === undefined || e.contentChanges.length === 0) {
return;
}
if (lastSelections.length !== e.contentChanges.length) {
return this._recordBreak();
}
const document = e.document,
firstChange = e.contentChanges[0],
firstSelection = lastSelections[0],
commonInsertedText = firstChange.text,
commonDeletionLength = firstChange.rangeLength,
commonOffsetFromActive =
firstChange.rangeOffset - document.offsetAt(firstSelection.active);
for (let i = 1, len = lastSelections.length; i < len; i++) {
const change = e.contentChanges[i];
if (change.text !== commonInsertedText || change.rangeLength !== commonDeletionLength) {
return this._recordBreak();
}
const offsetFromActive = change.rangeOffset - document.offsetAt(lastSelections[i].active);
if (offsetFromActive !== commonOffsetFromActive) {
return this._recordBreak();
}
}
// Merge consecutive events, if any.
const cursor = this.cursorFromEnd();
if (cursor.previousType() === Recording.ActionType.TextReplacement) {
// If there has been no selection change in the meantime, the text we're
// inserting now is at the start of the text we previously inserted.
const offset = cursor.previousOffset!,
previousOffsetFromActive = this._buffer[offset + 3];
if (commonOffsetFromActive === previousOffsetFromActive) {
this._buffer[offset + 1] = commonInsertedText + (this._buffer[offset + 1] as string);
this._buffer[offset + 2] = commonDeletionLength + (this._buffer[offset + 2] as number);
return;
}
}
this._startRecord(Recording.ActionType.TextReplacement);
this._buffer.push(commonInsertedText, commonDeletionLength, commonOffsetFromActive);
this._endRecord(Recording.ActionType.TextReplacement);
}
/**
* Replays the text replacement at the given index, and returns the index of
* the next record in the given buffer.
*/
public replayTextReplacement(buffer: Recorder.Buffer, index: number) {
// FIXME: type "hello world", remove "ld", replay; it should type
// "hello wor"
assert(
((buffer[index] as number) & Constants.NextMask) === Recording.ActionType.TextReplacement);
const insertedText = buffer[index + 1] as string,
deletionLength = buffer[index + 2] as number,
offsetFromActive = buffer[index + 3] as number;
const editor = vscode.window.activeTextEditor;
EditorRequiredError.throwUnlessAvailable(editor);
return editor.edit((editBuilder) => {
const document = editor.document;
for (const selection of editor.selections) {
const rangeStart = Positions.offset(selection.active, offsetFromActive, document);
assert(rangeStart !== undefined);
const rangeEnd = Positions.offset(rangeStart, deletionLength, document);
assert(rangeEnd !== undefined);
editBuilder.replace(new vscode.Range(rangeStart, rangeEnd), insertedText);
}
}, noUndoStops).then(() => index + 4);
}
/**
* Records a "break", indicating that a change that cannot be reliably
* replayed just happened.
*/
private _recordBreak() {
const buffer = this._buffer;
if (buffer.length > 0 && buffer[buffer.length - 1] !== Recording.ActionType.Break) {
this._startRecord(Recording.ActionType.Break);
this._endRecord(Recording.ActionType.Break);
this._activeRecordingTokens.splice(0).forEach((t) => t.dispose());
}
}
}
export namespace Recorder {
/**
* A buffer of `Recorder` values.
*/
export type Buffer = readonly RecordValue[];
/**
* The mutable version of `Buffer`.
*/
export type MutableBuffer = RecordValue[];
/**
* A cursor used to enumerate records in a `Recorder` or `Recording`.
*/
export class Cursor<T extends Recording.ActionType = Recording.ActionType> {
private _buffer: Buffer;
private _bufferIdx: number;
private _offset: number;
public constructor(
/**
* The recorder from which records are read.
*/
public readonly recorder: Recorder,
buffer: number,
offset: number,
) {
this._buffer = recorder.getBuffer(buffer);
this._bufferIdx = buffer;
this._offset = offset;
}
/**
* Returns the buffer storing the current record.
*/
public get buffer() {
return this._buffer;
}
/**
* Returns the offset of the current record in its buffer.
*/
public get offset() {
return this._offset;
}
/**
* Returns the offset of the previous record in its buffer, or `undefined`
* if the current record is the first of its buffer.
*/
public get previousOffset() {
return this._offset === 0
? undefined
: this._offset - Recording.entrySize[this.previousType()] - 1;
}
/**
* Returns a different instance of a `Cursor` that points to the same
* record.
*/
public clone() {
return new Cursor<T>(this.recorder, this._bufferIdx, this._offset);
}
/**
* Returns whether the current cursor is before or equal to the given
* cursor.
*/
public isBeforeOrEqual(other: Cursor) {
return this._bufferIdx < other._bufferIdx
|| (this._bufferIdx === other._bufferIdx && this._offset <= other._offset);
}
/**
* Returns whether the current cursor is after or equal to the given
* cursor.
*/
public isAfterOrEqual(other: Cursor) {
return this._bufferIdx > other._bufferIdx
|| (this._bufferIdx === other._bufferIdx && this._offset >= other._offset);
}
/**
* Replays the record pointed at by the cursor.
*/
public replay(context: Context.WithoutActiveEditor) {
return this.recorder.replay(this._buffer, this._offset, context);
}
/**
* Returns the type of the current record.
*/
public type() {
return ((this._buffer[this._offset] as number) & Constants.NextMask) as T;
}
/**
* Returns the type of the previous record.
*/
public previousType() {
return (this._buffer[this._offset] as number >> Constants.PrevShift) as Recording.ActionType;
}
/**
* Returns whether the cursor points to a record of the given type.
*/
public is<T extends Recording.ActionType>(type: T): this is Cursor<T> {
return this.type() as Recording.ActionType === type;
}
public commandDescriptor(): T extends Recording.ActionType.Command ? CommandDescriptor : never {
return this._buffer[this._offset + 1] as CommandDescriptor as any;
}
public commandArgument(): T extends Recording.ActionType.Command ? Record<string, any> : never {
return this._buffer[this._offset + 2] as object as any;
}
public insertedText(): T extends Recording.ActionType.TextReplacement ? string : never {
return this._buffer[this._offset + 1] as string as any;
}
public deletionLength(): T extends Recording.ActionType.TextReplacement ? number : never {
return this._buffer[this._offset + 2] as number as any;
}
public offsetFromActive(): T extends Recording.ActionType.TextReplacement ? number : never {
return this._buffer[this._offset + 3] as number as any;
}
public anchorOffsetDiff(
): T extends Recording.ActionType.SelectionTranslation ? number : never {
return this._buffer[this._offset + 1] as number as any;
}
public activeOffsetDiff(
): T extends Recording.ActionType.SelectionTranslation ? number : never {
return this._buffer[this._offset + 2] as number as any;
}
/**
* Switches to the next record, and returns `true` if the operation
* succeeded or `false` if the current record is the last one available.
*/
public next(): this is Cursor<Recording.ActionType> {
if (this._offset === this._buffer.length - 1) {
if (this._bufferIdx === this.recorder.bufferCount) {
return false;
}
this._bufferIdx++;
this._buffer = this.recorder.getBuffer(this._bufferIdx);
this._offset = 0;
return true;
}
this._offset += Recording.entrySize[this.type()] + 1;
return true;
}
/**
* Switches to the previous record, and returns `true` if the operation
* succeeded or `false` if the current record is the first one available.
*/
public previous(): this is Cursor<Recording.ActionType> {
if (this._offset === 0) {
if (this._bufferIdx === 0) {
return false;
}
this._bufferIdx--;
this._buffer = this.recorder.getBuffer(this._bufferIdx);
this._offset = this._buffer.length - 1;
return true;
}
this._offset -= Recording.entrySize[this.previousType()] + 1;
return true;
}
/**
* Returns whether the record pointed at by the cursor is included in the
* specified recording.
*/
public isInRecording(recording: Recording) {
return recording.offset <= this._offset && this._offset < recording.offset + recording.length
&& recording.buffer === this._buffer;
}
}
}
/**
* An ongoing `Recording`.
*/
export class ActiveRecording {
public constructor(
private readonly _notifyCompleted: () => Recording,
/**
* A cancellation token that will be cancelled if the recording is forcibly
* stopped due to an unknown change.
*/
public readonly cancellationToken: vscode.CancellationToken,
) {}
public complete() {
CancellationError.throwIfCancellationRequested(this.cancellationToken);
return this._notifyCompleted();
}
}
/**
* A recording of actions performed in VS Code.
*/
export class Recording {
public readonly buffer: Recorder.Buffer;
public readonly offset: number;
public readonly length: number;
public constructor(buffer: Recorder.Buffer, offset: number, length: number) {
this.buffer = buffer;
this.offset = offset;
this.length = length;
}
/**
* Replays the recording in the given context.
*/
public async replay(context = Context.WithoutActiveEditor.current) {
let offset = this.offset;
const buffer = this.buffer,
end = offset + this.length,
recorder = context.extension.recorder;
while (offset < end) {
offset = await recorder.replay(buffer, offset, context);
}
}
}
export namespace Recording {
/**
* The type of a recorded action.
*/
export const enum ActionType {
/**
* An action that cannot be reliably replayed and that interrupts a
* recording.
*/
Break,
/**
* An internal command invocation.
*/
Command,
/**
* An external command invocation.
*/
ExternalCommand,
/**
* A translation of all selections.
*/
SelectionTranslation,
/**
* A translation of all selections to the end of a line.
*/
SelectionTranslationToLineEnd,
/**
* An active text editor change.
*/
TextEditorChange,
/**
* A replacement of text near all selections.
*/
TextReplacement,
}
/**
* A recorded action.
*/
export type Entry<T extends ActionType = ActionType> = [T, ...EntryMap[T]];
/**
* Type map from entry type to entry values.
*/
export interface EntryMap {
readonly [ActionType.Break]: readonly [];
readonly [ActionType.Command]: readonly [command: CommandDescriptor, argument: object];
readonly [ActionType.ExternalCommand]: readonly [identifier: string, argument: object];
readonly [ActionType.SelectionTranslation]: readonly [anchorDiff: number, activeDiff: number];
readonly [ActionType.SelectionTranslationToLineEnd]: readonly [];
readonly [ActionType.TextEditorChange]: readonly [uri: vscode.Uri];
readonly [ActionType.TextReplacement]:
readonly [insertedText: string, deletionLength: number, offsetFromActive: number];
}
/**
* Maps an entry type to the size of its tuple in `EntryMap`.
*/
export const entrySize: {
readonly [K in keyof EntryMap]: EntryMap[K]["length"];
} = [0, 2, 2, 2, 0, 1, 3];
}

604
src/state/registers.ts Normal file
View File

@ -0,0 +1,604 @@
import * as vscode from "vscode";
import { ArgumentError, assert, Context, EditNotAppliedError, EditorRequiredError, prompt, Selections } from "../api";
import { SelectionBehavior } from "./modes";
import { noUndoStops } from "../utils/misc";
import { TrackedSelection } from "../utils/tracked-selection";
import { Recording } from "./recorder";
/**
* The base class for all registers.
*/
export abstract class Register {
/**
* The name of the register.
*/
public readonly abstract name: string;
/**
* The flags of the register, which define what a register can do.
*/
public readonly abstract flags: Register.Flags;
/**
* Returns whether the register is readable.
*/
public canRead(): this is Register.Readable {
return (this.flags & Register.Flags.CanRead) === Register.Flags.CanRead;
}
/**
* Returns whether the register is writeable.
*/
public canWrite(): this is Register.Writeable {
return (this.flags & Register.Flags.CanWrite) === Register.Flags.CanWrite;
}
/**
* Returns whether the register is selections-readeable.
*/
public canReadSelections(): this is Register.ReadableSelections {
return (this.flags & Register.Flags.CanReadSelections) === Register.Flags.CanReadSelections;
}
/**
* Returns whether the register is selections-writeable.
*/
public canWriteSelections(): this is Register.WriteableSelections {
return (this.flags & Register.Flags.CanWriteSelections) === Register.Flags.CanWriteSelections;
}
/**
* Returns whether the register can be used to replay recorded commands.
*/
public canReadRecordedCommands(): this is Register.ReadableWriteableMacros {
return (this.flags & Register.Flags.CanReadWriteMacros) === Register.Flags.CanReadWriteMacros;
}
/**
* Returns whether the register can be used to record commands.
*/
public canWriteRecordedCommands(): this is Register.ReadableWriteableMacros {
return (this.flags & Register.Flags.CanReadWriteMacros) === Register.Flags.CanReadWriteMacros;
}
/**
* Ensures that the register is readable.
*/
public ensureCanRead(): asserts this is Register.Readable {
this.checkFlags(Register.Flags.CanRead);
}
/**
* Ensures that the register is writeable.
*/
public ensureCanWrite(): asserts this is Register.Writeable {
this.checkFlags(Register.Flags.CanWrite);
}
/**
* Ensures that the register is selections-readeable.
*/
public ensureCanReadSelections(): asserts this is Register.ReadableSelections {
this.checkFlags(Register.Flags.CanReadSelections);
}
/**
* Ensures that the register is selections-writeable.
*/
public ensureCanWriteSelections(): asserts this is Register.WriteableSelections {
this.checkFlags(Register.Flags.CanWriteSelections);
}
/**
* Ensures that the register can be used to replay recorded commands.
*/
public ensureCanReadRecordedCommands(): asserts this is Register.ReadableWriteableMacros {
this.checkFlags(Register.Flags.CanReadWriteMacros);
}
/**
* Ensures that the register can be used to record commands.
*/
public ensureCanWriteRecordedCommands(): asserts this is Register.ReadableWriteableMacros {
this.checkFlags(Register.Flags.CanReadWriteMacros);
}
/**
* Returns whether the current register has the given flags.
*/
public hasFlags<F extends Register.Flags>(flags: F): this is Register.WithFlags<F> {
return (this.flags & flags) === flags;
}
/**
* @deprecated Use `Register.ensure*` instead.
*/
public checkFlags(flags: Register.Flags) {
const f = this.flags;
if ((flags & Register.Flags.CanRead) && !(f & Register.Flags.CanRead)) {
throw new Error(`register "${this.name}" cannot be used to read text`);
}
if ((flags & Register.Flags.CanReadSelections) && !(f & Register.Flags.CanReadSelections)) {
throw new Error(`register "${this.name}" cannot be used to read selections`);
}
if ((flags & Register.Flags.CanReadWriteMacros) && !(f & Register.Flags.CanReadWriteMacros)) {
throw new Error(`register "${this.name}" cannot be used to play or create recordings`);
}
if ((flags & Register.Flags.CanWrite) && !(f & Register.Flags.CanWrite)) {
throw new Error(`register "${this.name}" cannot be used to save text`);
}
if ((flags & Register.Flags.CanWriteSelections) && !(f & Register.Flags.CanWriteSelections)) {
throw new Error(`register "${this.name}" cannot be used to save selections`);
}
}
/**
* Returns the current register if it has the given flags, or throws an
* exception otherwise.
*/
public withFlags<F extends Register.Flags>(
flags: F,
): this extends Register.WithFlags<F> ? this : never {
// Note: using `ensureFlags` below throws an exception.
this.checkFlags(flags);
return this as any;
}
}
export namespace Register {
/**
* Flags describing the capabilities of a `Register`.
*/
export const enum Flags {
/** Register does not have any capability. */
None = 0,
/** Strings can be read from the register. */
CanRead = 1,
/** Strings can be written to the register. */
CanWrite = 2,
/** Selections can be read from the register. */
CanReadSelections = 4,
/** Selections can be written to the register. */
CanWriteSelections = 8,
/** Command histories can be read from or written to the register. */
CanReadWriteMacros = 16,
}
/**
* Given a set of `Flags`, returns what interfaces correspond to these flags.
*/
export type InterfaceFromFlags<F extends Flags>
= (F extends Flags.CanRead ? Readable : never)
| (F extends Flags.CanReadSelections ? ReadableSelections : never)
| (F extends Flags.CanReadWriteMacros ? ReadableWriteableMacros : never)
| (F extends Flags.CanWrite ? Writeable : never)
| (F extends Flags.CanWriteSelections ? WriteableSelections : never)
;
/**
* Given a set of `Flags`, returns the `Register` type augmented with the
* interfaces that correspond to these flags.
*/
export type WithFlags<F extends Flags> = Register & InterfaceFromFlags<F>;
export interface Readable {
get(): Thenable<readonly string[] | undefined>;
}
export interface Writeable {
set(values: readonly string[]): Thenable<void>;
}
export interface ReadableSelections {
getSelections(): readonly vscode.Selection[] | undefined;
getSelectionSet(): TrackedSelection.Set | undefined;
}
export interface WriteableSelections {
replaceSelectionSet(selections?: TrackedSelection.Set): TrackedSelection.Set | undefined;
}
export interface ReadableWriteableMacros {
getRecording(): Recording | undefined;
setRecording(recording: Recording): void;
}
}
/**
* A general-purpose register, which supports all operations on registers.
*/
class GeneralPurposeRegister extends Register implements Register.Readable,
Register.Writeable,
Register.ReadableSelections,
Register.WriteableSelections,
Register.ReadableWriteableMacros {
public readonly flags = Register.Flags.CanRead
| Register.Flags.CanReadSelections
| Register.Flags.CanReadWriteMacros
| Register.Flags.CanWrite
| Register.Flags.CanWriteSelections;
private _values?: readonly string[];
private _recording?: Recording;
private _selections?: TrackedSelection.Set;
public constructor(
public readonly name: string,
) {
super();
}
public set(values: readonly string[]) {
this._values = values;
return Promise.resolve();
}
public get() {
return Promise.resolve(this._values);
}
public getSelections() {
return this._selections?.restore();
}
public getSelectionSet() {
return this._selections;
}
public replaceSelectionSet(trackedSelections?: TrackedSelection.Set) {
const previousSelectionSet = this._selections;
this._selections = trackedSelections;
return previousSelectionSet;
}
public getRecording() {
return this._recording;
}
public setRecording(recording: Recording) {
this._recording = recording;
}
}
/**
* A special register whose behavior is defined by the functions given to it.
*/
class SpecialRegister extends Register implements Register.Readable,
Register.Writeable {
public readonly flags = this.setter === undefined
? Register.Flags.CanRead
: Register.Flags.CanRead | Register.Flags.CanWrite;
public constructor(
public readonly name: string,
public readonly getter: () => Thenable<readonly string[]>,
public readonly setter?: (values: readonly string[]) => Thenable<void>,
) {
super();
}
public get() {
return this.getter();
}
public set(values: readonly string[]) {
if (this.setter === undefined) {
throw new Error("cannot set read-only register");
}
return this.setter(values);
}
}
/**
* A special register that forwards operations to the system clipboard.
*/
class ClipboardRegister extends Register implements Register.Readable,
Register.Writeable {
private _lastStrings?: readonly string[];
private _lastRawText?: string;
public readonly name = '"';
public readonly flags = Register.Flags.CanRead | Register.Flags.CanWrite;
public get() {
return vscode.env.clipboard.readText().then((text) =>
text === this._lastRawText ? this._lastStrings : [text],
);
}
public set(values: readonly string[]) {
let newline = "\n";
if (Context.currentOrUndefined?.document?.eol === vscode.EndOfLine.CRLF) {
newline = "\r\n";
}
this._lastStrings = values;
this._lastRawText = values.join(newline);
return vscode.env.clipboard.writeText(this._lastRawText);
}
}
function activeEditor() {
const activeEditor = vscode.window.activeTextEditor;
EditorRequiredError.throwUnlessAvailable(activeEditor);
return activeEditor;
}
/**
* A set of registers.
*/
export abstract class RegisterSet {
private readonly _named = new Map<string, Register>();
private readonly _letters = Array.from(
{ length: 26 },
(_, i) => new GeneralPurposeRegister(String.fromCharCode(97 + i)) as Register,
);
private readonly _digits = Array.from(
{ length: 10 },
(_, i) => new SpecialRegister((i + 1).toString(), () => Promise.resolve(this._lastMatches[i])),
);
private _lastMatches: readonly (readonly string[])[] = [];
/**
* The '"' (`dquote`) register, mapped to the system clipboard and default
* register for edit operations.
*/
public readonly dquote = new ClipboardRegister();
/**
* The "/" (`slash`) register, default register for search / regex operations.
*/
public readonly slash = new GeneralPurposeRegister("/");
/**
* The "@" (`arobase`) register, default register for recordings (aka macros).
*/
public readonly arobase = new GeneralPurposeRegister("@");
/**
* The "^" (`caret`) register, default register for saving selections.
*/
public readonly caret = new GeneralPurposeRegister("^");
/**
* The "|" (`pipe`) register, default register for outputs of external
* commands.
*/
public readonly pipe = new GeneralPurposeRegister("|");
/**
* The "%" (`percent`) register, mapped to the name of the current document.
*/
public readonly percent = new SpecialRegister(
"%",
() => Promise.resolve([activeEditor().document.fileName]),
(values) => {
if (values.length !== 1) {
return Promise.reject(new ArgumentError("a single file name must be selected"));
}
return vscode.workspace.openTextDocument(values[0]).then(() => {});
},
);
/**
* The "." (`dot`) register, mapped to the contents of the current selections.
*/
public readonly dot = new SpecialRegister(
".",
() => {
const editor = activeEditor(),
document = editor.document,
selectionBehavior = Context.currentOrUndefined?.mode?.selectionBehavior,
selections = selectionBehavior === SelectionBehavior.Character
? Selections.fromCharacterMode(editor.selections, document)
: editor.selections;
return Promise.resolve(selections.map(document.getText.bind(document)));
},
(values) => {
const editor = activeEditor();
if (values.length !== editor.selections.length) {
return Promise.reject(new ArgumentError("as many selections as values must be given"));
}
return editor.edit((editBuilder) => {
const document = editor.document,
selectionBehavior = Context.currentOrUndefined?.mode?.selectionBehavior,
selections = selectionBehavior === SelectionBehavior.Character
? Selections.fromCharacterMode(editor.selections, document)
: editor.selections;
for (let i = 0; i < selections.length; i++) {
editBuilder.replace(selections[i], values[i]);
}
}, noUndoStops).then((succeeded) => EditNotAppliedError.throwIfNotApplied(succeeded));
},
);
/**
* The read-only "#" (`hash`) register, mapped to the indices of the current
* selections.
*/
public readonly hash = new SpecialRegister("#", () =>
Promise.resolve(activeEditor().selections.map((_, i) => i.toString())),
);
/**
* The read-only "_" (`underscore`) register, mapped to an empty string.
*/
public readonly underscore = new SpecialRegister("_", () => Promise.resolve([""]));
/**
* The ":" (`colon`) register.
*
* In Kakoune it is mapped to the last entered command, but since we don't
* have access to that information in Dance, we map it to a prompt.
*/
public readonly colon = new SpecialRegister(":", () =>
prompt({ prompt: ":" }).then((result) => [result]),
);
/**
* The `null` register, which forgets selections written to it and always
* returns no strings.
*/
public readonly null = new SpecialRegister(
"null",
() => Promise.resolve([]),
() => Promise.resolve(),
);
public constructor() {
for (const [longName, register] of [
["dquote", this.dquote] as const,
["slash", this.slash] as const,
["arobase", this.arobase] as const,
["caret", this.caret] as const,
["pipe", this.pipe] as const,
["percent", this.percent] as const,
["dot", this.dot] as const,
["hash", this.hash] as const,
["underscore", this.underscore] as const,
["colon", this.colon] as const,
]) {
this._letters[longName.charCodeAt(0) - 97 /* a */] = register;
this._named.set(longName, register);
}
this._named.set("", this.null);
this._named.set("null", this.null);
}
/**
* Returns the register with the given name or identified by the given key if
* the input is one-character long.
*/
public get(key: string): Register {
if (key.length === 1) {
const charCode = key.charCodeAt(0);
switch (charCode) {
case 34: // "
return this.dquote;
case 47: // /
return this.slash;
case 64: // @
return this.arobase;
case 94: // ^
return this.caret;
case 124: // |
return this.pipe;
case 37: // %
return this.percent;
case 46: // .
return this.dot;
case 35: // #
return this.hash;
case 95: // _
return this.underscore;
case 58: // :
return this.colon;
default:
if (charCode >= 97 /* a */ && charCode <= 122 /* z */) {
return this._letters[charCode - 97];
}
if (charCode >= 65 /* A */ && charCode <= 90 /* Z */) {
return this._letters[charCode - 65];
}
if (charCode >= 49 /* 1 */ && charCode <= 57 /* 9 */) {
return this._digits[charCode - 49];
}
}
}
key = key.toLowerCase();
let register = this._named.get(key.toLowerCase());
if (register === undefined) {
this._named.set(key, register = new GeneralPurposeRegister(key));
}
return register;
}
/**
* Updates the contents of the numeric registers to hold the groups matched by
* the last `RegExp` search operation.
*
* @deprecated Do not call -- internal implementation detail.
*/
public updateRegExpMatches(matches: RegExpMatchArray[]) {
assert(matches.length > 0);
const transposed = [] as string[][],
groupsCount = matches[0].length;
for (let i = 1; i < groupsCount; i++) {
const strings = [] as string[];
for (const match of matches) {
strings.push(match[i]);
}
transposed.push(strings);
}
this._lastMatches = transposed;
}
}
/**
* The set of all registers linked to a specific document.
*/
export class DocumentRegisters extends RegisterSet {
public constructor(
/**
* The document to which the registers are linked.
*/
public readonly document: vscode.TextDocument,
) {
super();
}
}
/**
* The set of all registers.
*/
export class Registers extends RegisterSet {
private readonly _perDocument = new WeakMap<vscode.TextDocument, DocumentRegisters>();
/**
* Returns the registers linked to the given document.
*/
public forDocument(document: vscode.TextDocument) {
let registers = this._perDocument.get(document);
if (registers === undefined) {
this._perDocument.set(document, registers = new DocumentRegisters(document));
}
return registers;
}
}

92
src/state/status-bar.ts Normal file
View File

@ -0,0 +1,92 @@
import * as vscode from "vscode";
/**
* Controls the Dance status bar item.
*/
export class StatusBar implements vscode.Disposable {
private readonly _segments: StatusBar.Segment[] = [];
public readonly activeModeSegment: StatusBar.Segment;
public readonly recordingSegment: StatusBar.Segment;
public readonly countSegment: StatusBar.Segment;
public readonly registerSegment: StatusBar.Segment;
public readonly errorSegment: StatusBar.Segment;
public constructor() {
this.activeModeSegment = this.addSegment("Dance - Set mode", "zap", "dance.modes.set");
this.recordingSegment =
this.addSegment("Dance - Stop recording", "record", "dance.history.recording.stop");
this.countSegment = this.addSegment(
"Dance - Reset count",
"symbol-number",
{ command: "dance.updateCount", arguments: [{ input: "0" }], title: "" },
);
this.registerSegment = this.addSegment(
"Dance - Unset register",
"clone",
{ command: "dance.selectRegister", arguments: [{ input: "" }], title: "" },
);
this.errorSegment = this.addSegment(
"Dance - Dismiss error",
"error",
"dance.ignore",
);
this.errorSegment.statusBarItem.backgroundColor =
new vscode.ThemeColor("statusBarItem.errorBackground");
}
public dispose() {
this._segments.splice(0).forEach((s) => s.dispose());
}
private addSegment(tooltip: string, icon: string, command: string | vscode.Command) {
const segment = new StatusBar.Segment(tooltip, icon, 100 - this._segments.length, command);
this._segments.push(segment);
return segment;
}
}
export namespace StatusBar {
export class Segment implements vscode.Disposable {
private readonly _statusBarItem: vscode.StatusBarItem;
private _content?: string;
public get content() {
return this._content;
}
public get statusBarItem() {
return this._statusBarItem;
}
public constructor(
public readonly name: string,
public readonly icon: string,
public readonly priority: number,
command: string | vscode.Command,
) {
this._statusBarItem =
vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, priority);
this._statusBarItem.tooltip = name;
this._statusBarItem.command = command;
}
public dispose() {
this._statusBarItem.dispose();
}
public setContent(content?: string) {
this._content = content;
if (content) {
this._statusBarItem.text = `$(${this.icon}) ${content}`;
this._statusBarItem.show();
} else {
this._statusBarItem.hide();
}
}
}
}

View File

@ -1,3 +0,0 @@
export function assert(condition: boolean) {
console.assert(condition);
}

View File

@ -4,7 +4,10 @@ import * as vscode from "vscode";
// == CHARACTER SETS ===========================================================================
// ===============================================================================================
const blankCharacters
/**
* A list containing all blank characters.
*/
export const blankCharacters
= "\r\n\t "
+ String.fromCharCode(
0xa0,

231
src/utils/disposables.ts Normal file
View File

@ -0,0 +1,231 @@
import * as vscode from "vscode";
import { Context } from "../api";
import { PerEditorState } from "../state/editors";
declare class WeakRef<T extends object> {
public constructor(value: T);
public deref(): T | undefined;
}
export interface NotifyingDisposable extends vscode.Disposable {
readonly onDisposed: vscode.Event<this>;
}
/**
* A wrapper around a set of `vscode.Disposable`s that can be scheduled to
* automatically be disposed (along with its wrapped disposables) when a certain
* event happens.
*/
export class AutoDisposable implements vscode.Disposable {
private _boundDispose?: AutoDisposable["dispose"] = this.dispose.bind(this);
private readonly _disposables: vscode.Disposable[];
public constructor(disposables: vscode.Disposable[] = []) {
this._disposables = disposables;
}
/**
* Disposes of all the wrapped disposables.
*/
public dispose() {
this._boundDispose = undefined;
const disposables = this._disposables;
for (let i = 0, len = disposables.length; i < len; i++) {
disposables[i].dispose();
}
disposables.length = 0;
}
/**
* Whether the `AutoDisposable` has been disposed of.
*/
public get isDisposed() {
return this._boundDispose === undefined;
}
/**
* Adds a new disposable that will be disposed of when this `AutoDisposable`
* is itself disposed of.
*
* Calling this after disposing of the `AutoDisposable` will immediately
* dispose of the given disposable.
*/
public addDisposable(disposable: vscode.Disposable) {
if (this._boundDispose === undefined) {
disposable.dispose();
return this;
}
this._disposables.push(disposable);
return this;
}
public addNotifyingDisposable(disposable: NotifyingDisposable) {
return this.addDisposable(disposable).disposeOnEvent(disposable.onDisposed);
}
/**
* Automatically disposes of this disposable when the given event is
* triggered.
*/
public disposeOnEvent<T>(event: vscode.Event<T>) {
const boundDispose = this._boundDispose;
if (boundDispose !== undefined) {
this._disposables.push(event(boundDispose));
}
return this;
}
/**
* Automatically disposes of this disposable when the given promise is
* resolved.
*/
public disposeOnPromiseResolution<T>(thenable: Thenable<T>) {
if (this._boundDispose === undefined) {
return this;
}
const weakThis = new WeakRef(this);
thenable.then(() => weakThis.deref()?.dispose());
return this;
}
/**
* Automatically disposes of this disposable when the cancellation of the
* given `CancellationToken` is requested.
*/
public disposeOnCancellation(token: vscode.CancellationToken) {
if (this._boundDispose === undefined) {
return this;
}
if (token.isCancellationRequested) {
this.dispose();
return this;
}
return this.disposeOnEvent(token.onCancellationRequested);
}
/**
* Automatically disposes of this disposable when `ms` milliseconds have
* elapsed.
*/
public disposeAfterTimeout(ms: number) {
const boundDispose = this._boundDispose;
if (boundDispose === undefined) {
return this;
}
const token = setTimeout(boundDispose, ms);
this._disposables.push({
dispose() {
clearTimeout(token);
},
});
return this;
}
public disposeOnUserEvent(event: AutoDisposable.Event, context: Context) {
const editorState = context.extension.editors.getState(context.editor as any)!;
let eventName: AutoDisposable.EventType,
eventOpts: Record<string, unknown>;
if (Array.isArray(event)) {
if (event.length === 0) {
throw new Error();
}
if (typeof event[0] === "string") {
eventName = event[0] as AutoDisposable.EventType;
} else {
throw new Error();
}
if (event.length === 2) {
eventOpts = event[1];
if (typeof eventOpts !== "object" || eventOpts === null) {
throw new Error();
}
} else if (event.length === 1) {
eventOpts = {};
} else {
throw new Error();
}
} else if (typeof event === "string") {
eventName = event;
eventOpts = {};
} else {
throw new Error();
}
switch (eventName) {
case AutoDisposable.EventType.OnEditorWasClosed:
this.disposeOnEvent(editorState.onEditorWasClosed);
break;
case AutoDisposable.EventType.OnModeDidChange:
const except = [] as string[];
if (Array.isArray(eventOpts.except)) {
except.push(...eventOpts.except);
} else if (typeof eventOpts.except === "string") {
except.push(eventOpts.except);
}
const include = [] as string[];
if (Array.isArray(eventOpts.include)) {
include.push(...eventOpts.include);
} else if (typeof eventOpts.include === "string") {
include.push(eventOpts.include);
}
editorState.extension.editors.onModeDidChange((e) => {
if (e === editorState && !except.includes(e.mode.name)
&& (include.length === 0 || include.includes(e.mode.name))) {
this.dispose();
}
}, undefined, this._disposables);
break;
case AutoDisposable.EventType.OnSelectionsDidChange:
vscode.window.onDidChangeTextEditorSelection((e) => {
if (editorState.editor === e.textEditor
&& e.kind === vscode.TextEditorSelectionChangeKind.Mouse) {
this.dispose();
}
}, undefined, this._disposables);
break;
default:
throw new Error();
}
}
}
export namespace AutoDisposable {
export const enum EventType {
OnEditorWasClosed = "editor-was-closed",
OnModeDidChange = "mode-did-change",
OnSelectionsDidChange = "selections-did-change",
}
export type Event = EventType
| readonly [EventType.OnModeDidChange,
{ except?: string | string[]; include?: string | string[] }];
}

59
src/utils/misc.ts Normal file
View File

@ -0,0 +1,59 @@
import * as vscode from "vscode";
import { Context, prompt, Selections } from "../api";
import { Input, SetInput } from "../commands";
/**
* An object passed to `vscode.TextEditor.edit` to indicate that no undo stops
* should be implicitly inserted.
*/
export const noUndoStops: Parameters<vscode.TextEditor["edit"]>[1] =
Object.freeze({ undoStopBefore: false, undoStopAfter: false });
const dummyPosition = new vscode.Position(0, 0),
dummyRange = new vscode.Range(dummyPosition, dummyPosition),
dummyUndoStops: Parameters<vscode.TextEditor["edit"]>[1] =
Object.freeze({ undoStopBefore: false, undoStopAfter: true });
/**
* Performs a dummy edit on a text document, inserting an undo stop.
*/
export function performDummyEdit(editor: vscode.TextEditor) {
// VS Code ignores edits where no interaction is performed with the editor, so
// we delete an empty range at the start of the document.
return editor
.edit((editBuilder) => editBuilder.delete(dummyRange), dummyUndoStops)
.then(() => {});
}
export async function manipulateSelectionsInteractively<I, R>(
_: Context,
input: Input<I>,
setInput: SetInput<R>,
interactive: boolean,
options: vscode.InputBoxOptions,
f: (input: string | I, selections: readonly vscode.Selection[]) => Thenable<R>,
) {
const selections = _.selections;
function execute(input: string | I) {
return _.runAsync(() => f(input, selections));
}
function undo() {
Selections.set(selections);
}
if (input === undefined) {
setInput(await prompt.interactive(execute, undo, options, interactive));
} else {
await execute(input);
}
}
export const workspaceSettingsPropertyNames = [
"workspaceFolderValue",
"workspaceFolderLanguageValue",
"workspaceValue",
"workspaceLanguageValue",
] as const;

View File

@ -1,131 +0,0 @@
import * as vscode from "vscode";
export function prompt(opts: vscode.InputBoxOptions, cancellationToken?: vscode.CancellationToken) {
return vscode.window.showInputBox(opts, cancellationToken);
}
export function promptRegex(flags?: string, cancellationToken?: vscode.CancellationToken) {
return prompt(
{
prompt: "Selection RegExp",
validateInput(input: string) {
try {
new RegExp(input);
return undefined;
} catch {
return "Invalid ECMA RegExp.";
}
},
},
cancellationToken,
).then((x) => x === undefined ? undefined : new RegExp(x, flags));
}
export function keypress(
cancellationToken?: vscode.CancellationToken,
): Thenable<string | undefined> {
return new Promise((resolve, reject) => {
try {
let done = false;
const subscription = vscode.commands.registerCommand("type", ({ text }: { text: string }) => {
if (!done) {
subscription.dispose();
done = true;
resolve(text);
}
});
cancellationToken?.onCancellationRequested(() => {
if (!done) {
subscription.dispose();
done = true;
resolve(undefined);
}
});
} catch {
reject(new Error("Unable to listen to keyboard events; is an extension "
+ 'overriding the "type" command (e.g VSCodeVim)?'));
}
});
}
export function promptInList(
canPickMany: true,
items: [string, string][],
cancellationToken?: vscode.CancellationToken,
): Thenable<undefined | number[]>;
export function promptInList(
canPickMany: false,
items: [string, string][],
cancellationToken?: vscode.CancellationToken,
): Thenable<undefined | number>;
export function promptInList(
canPickMany: boolean,
items: [string, string][],
cancellationToken?: vscode.CancellationToken,
): Thenable<undefined | number | number[]> {
return new Promise<undefined | number | number[]>((resolve) => {
const quickPick = vscode.window.createQuickPick(),
quickPickItems = [] as vscode.QuickPickItem[];
let isCaseSignificant = false;
for (let i = 0; i < items.length; i++) {
const [label, description] = items[i];
quickPickItems.push({ label, description });
isCaseSignificant = isCaseSignificant || label.toLowerCase() !== label;
}
quickPick.title = "Object";
quickPick.items = quickPickItems;
quickPick.placeholder = "Press one of the below keys.";
quickPick.onDidChangeValue((key) => {
if (!isCaseSignificant) {
key = key.toLowerCase();
}
const index = items.findIndex((x) => x[0].split(", ").includes(key));
quickPick.dispose();
if (canPickMany) {
resolve(index === -1 ? undefined : [index]);
} else {
resolve(index === -1 ? undefined : index);
}
});
quickPick.onDidAccept(() => {
let picked = quickPick.selectedItems;
if (picked !== undefined && picked.length === 0) {
picked = quickPick.activeItems;
}
quickPick.dispose();
if (picked === undefined) {
resolve(undefined);
}
if (canPickMany) {
resolve(picked.map((x) => items.findIndex((item) => item[1] === x.description)));
} else {
resolve(items.findIndex((x) => x[1] === picked[0].description));
}
});
cancellationToken?.onCancellationRequested(() => {
quickPick.dispose();
resolve(undefined);
});
quickPick.show();
});
}

1705
src/utils/regexp.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,66 +0,0 @@
import * as vscode from "vscode";
/**
* A selection that has been saved, and that is being tracked.
*/
export class SavedSelection {
private _anchorOffset: number;
private _activeOffset: number;
public constructor(anchorOffset: number, activeOffset: number) {
this._anchorOffset = anchorOffset;
this._activeOffset = activeOffset;
}
/** The offset of the anchor in its document. */
public get anchorOffset() {
return this._anchorOffset;
}
/** The offset of the active position in its document. */
public get activeOffset() {
return this._activeOffset;
}
/** Whether the selection is reversed (i.e. `activeOffset < anchorOffset`). */
public get isReversed() {
return this._activeOffset < this._anchorOffset;
}
/**
* Returns the anchor of the saved selection.
*/
public anchor(document: vscode.TextDocument) {
return document.positionAt(this._anchorOffset);
}
/**
* Returns the active position of the saved selection.
*/
public active(document: vscode.TextDocument) {
return document.positionAt(this._activeOffset);
}
/**
* Returns the saved selection, restored in the given document.
*/
public selection(document: vscode.TextDocument) {
return new vscode.Selection(this.anchor(document), this.active(document));
}
/**
* Updates the underlying selection to reflect a change in its document.
*/
public updateAfterDocumentChanged(e: vscode.TextDocumentContentChangeEvent) {
const diff = e.text.length - e.rangeLength,
offset = e.rangeOffset + e.rangeLength;
if (offset <= this._activeOffset) {
this._activeOffset += diff;
}
if (offset <= this._anchorOffset) {
this._anchorOffset += diff;
}
}
}

View File

@ -1,538 +0,0 @@
import * as vscode from "vscode";
import { CommandState } from "../commands";
import { EditorState } from "../state/editor";
import { SelectionBehavior } from "../state/extension";
export const DocumentStart = new vscode.Position(0, 0);
/**
* Direction of an operation.
*/
export const enum Direction {
/** Forward direction (`1`). */
Forward = 1,
/** Backward direction (`-1`). */
Backward = -1,
}
/**
* Whether or not an operation should expend a selection.
*/
export type ExtendBehavior = boolean;
/** Forward direction. */
export const Forward = Direction.Forward;
/** Backward direction. */
export const Backward = Direction.Backward;
/** Do extend. */
export const Extend = true as ExtendBehavior;
/** Do not extend. */
export const DoNotExtend = false as ExtendBehavior;
export class SelectionHelper<State extends { selectionBehavior: SelectionBehavior }> {
public static for(editorState: EditorState): SelectionHelper<EditorState>;
public static for<State extends { selectionBehavior: SelectionBehavior }>(
editorState: EditorState,
state: State,
): SelectionHelper<State>;
public static for(editorState: EditorState, state?: any) {
// TODO: Caching
return new SelectionHelper(editorState, state ?? editorState);
}
public readonly selectionBehavior = this.state.selectionBehavior;
/**
* Get the "cursor active" Coord of a selection.
*
* In other words, the basis Coord for moving, etc. of the selection. This
* method handles special cases for non-directional selections correctly.
* @param selection
*/
public activeCoord(selection: vscode.Selection): Coord {
if (
this.selectionBehavior === SelectionBehavior.Caret
|| selection.active.isEqual(DocumentStart)
|| selection.isEmpty
|| selection.isReversed
) {
return selection.active;
}
return this.coordAt(this.offsetAt(selection.active) - 1);
}
/**
* Apply a transformation to each selection.
*
* If all selections are to be removed, an alert will be raised and selections
* are left untouched (or replaced by fallback, if provided).
*
* @param mapper a function takes the old selection and return a new one or
* `{remove: true}` with optional fallback.
*
* @see moveActiveCoord,jumpTo,seekToRange for utilities to create mappers for
* common select operations
*/
public mapEach(mapper: SelectionMapper<State>): void {
const newSelections: vscode.Selection[] = [];
const editor = this.editor;
let acceptFallback = true;
const len = editor.selections.length;
for (let i = 0; i < len; i++) {
const moveResult = mapper(editor.selections[i], this, i);
if (moveResult === RemoveSelection || "remove" in moveResult) {
if (acceptFallback) {
newSelections.push(moveResult.fallback || editor.selections[i]);
}
} else {
if (acceptFallback) {
newSelections.length = 0; // Clear all fallback selections so far.
acceptFallback = false;
}
newSelections.push(moveResult);
}
}
if (acceptFallback) {
throw new Error("no selections remaining");
}
editor.selections = newSelections;
}
/**
* Return a selection that spans from and to (both inclusive).
* @param from the coordinate of the starting symbol to include
* @param to the coordinate of the ending symbol to include
* @param singleCharDirection the direction of selection when from equals to
*/
public selectionBetween(
from: Coord,
to: Coord,
singleCharDirection: Direction = Forward,
): vscode.Selection {
// TODO: Remove this.coordAt and this.offsetAt. Use lineAt for shifting to
// the next position.
if (from.isBefore(to) || (from.isEqual(to) && singleCharDirection === Forward)) {
// Forward: 0123456 ==select(1, 4)=> 0<1234|56
// Need to increment `to` to include the last character.
const active = this.coordAt(this.offsetAt(to) + 1);
return new vscode.Selection(from, active);
} else {
// Reverse: 0123456 ==select(4, 1)=> 0|1234>56
// Need to increment `from` to include the last character.
const anchor = this.coordAt(this.offsetAt(from) + 1);
return new vscode.Selection(anchor, to);
}
}
public extend(oldSelection: vscode.Selection, to: Coord): vscode.Selection {
// TODO: Remove this.coordAt and this.offsetAt. Use lineAt for shifting to
// the next / previous position.
let { anchor } = oldSelection;
if (anchor.isBeforeOrEqual(to)) {
// The resulting selection will be forward-facing.
if (this.selectionBehavior === SelectionBehavior.Character && oldSelection.isReversed) {
// Flipping selections in the opposite direction: 0|1>2345 to 0<12345|
// Need to push anchor backwards so that the first symbol is included.
anchor = this.coordAt(this.offsetAt(anchor) - 1);
}
if (
this.selectionBehavior === SelectionBehavior.Character
|| to.isAfterOrEqual(oldSelection.active)
) {
// Moving forward (or non-directional): 01|23>456 ==(to 4)==> 01|234>56
// Need to increment to include the the symbol at `to`.
const active = this.coordAt(this.offsetAt(to) + 1);
return new vscode.Selection(anchor, active);
} else {
// Moving backwards: 01|23>456 ==(to 2)==> 01|>23456
// The symbol at `to` is excluded in this case, since we're
// "deselecting" that character.
return new vscode.Selection(anchor, to);
}
} else {
// The resulting selection will be (most likely) backwards-facing.
const afterTo = this.coordAt(this.offsetAt(to) + 1);
if (this.selectionBehavior === SelectionBehavior.Character) {
if (
(!oldSelection.isReversed && oldSelection.anchor.isEqual(to))
|| (oldSelection.isReversed && oldSelection.anchor.isEqual(afterTo))
) {
// Special case one character selections to face forward:
return new vscode.Selection(to, afterTo);
} else if (!oldSelection.isReversed) {
// Flipping selections in the opposite direction: 0123<4|5 to |01234>5
// Need to push anchor forward so that the last symbol is included.
anchor = this.coordAt(this.offsetAt(anchor) + 1);
}
}
if (
this.selectionBehavior === SelectionBehavior.Character
|| to.isBeforeOrEqual(oldSelection.active)
) {
// Moving backward (or non-directional): 012<34|5 ==(to 2)==> 01<234|5
// Include the symbol at `to`.
return new vscode.Selection(anchor, to);
} else {
// Moving forward (or non-directional): 012<34|5 ==(to 4)==> 01234<|5
// The symbol at `to` is excluded in this case, since we're
// "deselecting" that character.
return new vscode.Selection(anchor, afterTo);
}
}
}
/** Get the next position in document. */
public nextPos(pos: vscode.Position): vscode.Position {
// TODO: Optimize
return this.coordAt(this.offsetAt(pos) + 1);
}
/** Get the previous position in document. */
public prevPos(pos: vscode.Position): vscode.Position {
// TODO: Optimize
return this.coordAt(this.offsetAt(pos) - 1);
}
/**
* Get the line and character of an offset in the document.
*
* This should be used instead of `this.coordAt` to make
* the intention clear and also allow future optimization like caching.
* @param coordOffset the nth symbol (or line break) in the document
*/
public coordAt(coordOffset: number): Coord {
// TODO: Caching
return this.editor.document.positionAt(coordOffset);
}
/**
* Get the total sequence number of symbol (or line break) in the document.
*
* This should be used instead of `this.offsetAt` to make
* the intention clear and also allow future optimization like caching.
* @param coord the line and character of the symbol in the document
*/
public offsetAt(coord: Coord): CoordOffset {
// TODO: Caching
return this.editor.document.offsetAt(coord);
}
public endLine(selection: vscode.Selection) {
if (selection.end === selection.active && selection.end.character === 0) {
return selection.end.line - 1;
} else {
return selection.end.line;
}
}
public activeLine(selection: vscode.Selection) {
if (selection.end === selection.active && selection.end.character === 0) {
return selection.active.line - 1;
} else {
return selection.active.line;
}
}
public endCharacter(selection: vscode.Selection, forPositionConstructor = false) {
if (
this.selectionBehavior === SelectionBehavior.Character
&& selection.end === selection.active
&& selection.end.character === 0
) {
return forPositionConstructor
? Number.MAX_SAFE_INTEGER
: this.editor.document.lineAt(selection.end.line - 1).text.length + 1;
} else {
return selection.end.character;
}
}
public activeCharacter(selection: vscode.Selection, forPositionConstructor = false) {
if (
this.selectionBehavior === SelectionBehavior.Character
&& selection.end === selection.active
&& selection.active.character === 0
) {
return forPositionConstructor
? Number.MAX_SAFE_INTEGER
: this.editor.document.lineAt(selection.active.line - 1).text.length + 1;
} else {
return selection.active.character;
}
}
public isSingleLine(selection: vscode.Selection) {
return selection.isSingleLine || selection.start.line === this.endLine(selection);
}
public isEntireLine(selection: vscode.Selection) {
return (
selection.start.character === 0
&& selection.end.character === 0
&& selection.start.line === selection.end.line - 1
);
}
public isEntireLines(selection: vscode.Selection) {
return (
selection.start.character === 0
&& selection.end.character === 0
&& selection.start.line !== selection.end.line
);
}
/**
* The selection length of the given selection, as defined by
* `offsetAt(active) - offsetAt(anchor)`.
*
* If the selection is reversed, the selection length is negative.
*/
public selectionLength(selection: vscode.Selection) {
if (selection.isSingleLine) {
return selection.active.character - selection.anchor.character;
}
return this.offsetAt(selection.active) - this.offsetAt(selection.anchor);
}
public selectionFromLength(anchorCoord: Coord, length: number) {
return new vscode.Selection(anchorCoord, this.coordAt(this.offsetAt(anchorCoord) + length));
}
public lastCoord(): Coord {
const document = this.editor.document;
const lastLineText = document.lineAt(document.lineCount - 1).text;
if (lastLineText.length > 0) {
return new Coord(document.lineCount - 1, lastLineText.length - 1);
}
if (document.lineCount >= 2) {
return new Coord(document.lineCount - 2, document.lineAt(document.lineCount - 2).text.length);
}
return DocumentStart;
}
/** DEBUG ONLY method to visualize an offset. DO NOT leave calls in code. */
public _visualizeOffset(offset: number): string {
const position = this.coordAt(offset);
return this._visualizePosition(position);
}
/** DEBUG ONLY method to visualize a position. DO NOT leave calls in code. */
public _visualizePosition(position: vscode.Position): string {
const text = this.editor.document.lineAt(position.line).text;
return (
position.line
+ ": "
+ text.substr(0, position.character)
+ "|"
+ text.substr(position.character)
);
}
/** DEBUG ONLY method to visualize a Coord. DO NOT leave calls in code. */
public _visualizeCoord(coord: Coord): string {
const text = this.editor.document.lineAt(coord.line).text;
let visualLine;
if (coord.character === text.length) {
visualLine = text + "⏎";
} else {
visualLine
= text.substr(0, coord.character)
+ `[${text[coord.character]}]`
+ text.substr(coord.character + 1);
}
return `L${coord.line}: ${visualLine}`;
}
public readonly editor: vscode.TextEditor;
private constructor(public readonly editorState: EditorState, public readonly state: State) {
this.editor = editorState.editor;
}
}
/**
* A coordinate (line, character) in the document. It stands for the c-th
* text symbol at the l-th line in the document (both zero-indexed).
*
* Note that a Coord should always be interpreted to be pointed at a text symbol
* or the line break at the end of the current line (e.g. the 'b' in 'abc').
* It should not be mistaken for a Position between symbols (a|bc in 'abc').
*/
export interface Coord extends vscode.Position {
// Dummy interface so that it shows as its type in generated docs, IDE hover
// tooltips, etc. Do not add fields here.
}
export const Coord = vscode.Position; // To allow sugar like `new Coord(1, 2)`.
export type CoordOffset = number;
export type SelectionMapper<
State extends { selectionBehavior: SelectionBehavior } = CommandState
> = (
selection: vscode.Selection,
helper: SelectionHelper<State>,
i: number,
) => vscode.Selection | { remove: true; fallback?: vscode.Selection };
export type CoordMapper = (
oldActive: Coord,
helper: SelectionHelper<CommandState>,
i: number,
) => Coord | typeof RemoveSelection;
export type SeekFunc = (
oldActive: Coord,
helper: SelectionHelper<CommandState>,
i: number,
) => [Coord, Coord] | { remove: true; fallback?: [Coord | undefined, Coord] };
export const RemoveSelection: { remove: true } = { remove: true };
/**
* Create a SelectionMapper that moves selection's active to a new coordinate.
*
* @summary The mapper created is useful for commands that "drags" to a
* character such as `f` (selectTo) without moving anchor.
*
* When extending, the new selection will keep the same anchor caret /
* character (depending on `this.selectionBehavior`). When not extending, the
* new selection anchors to the old active caret / character. Either way,
* the new active will always sweep over the symbol at the coordinate
* returned, selecting or deselecting it as appropriate.
*
* @param activeMapper a function that takes an old active coordinate and return
* the Coord of the symbol to move to or `RemoveSelection`
* @param extend if Extend, the new selection will keep the anchor caret /
* character. Otherwise, it is anchored to old active.
* @returns a SelectionMapper that is suitable for SelectionHelper#mapEach
*/
export function moveActiveCoord(
activeMapper: CoordMapper,
extend: ExtendBehavior,
): SelectionMapper {
return (selection, helper, i) => {
const oldActive = helper.activeCoord(selection);
const newActive = activeMapper(oldActive, helper, i);
if ("remove" in newActive) {
return RemoveSelection;
}
if (extend) {
return helper.extend(selection, newActive);
}
if (helper.selectionBehavior === SelectionBehavior.Caret) {
// TODO: Optimize to avoid coordAt / offsetAt.
const activePos = selection.active.isBeforeOrEqual(newActive)
? helper.coordAt(helper.offsetAt(newActive) + 1)
: newActive;
return new vscode.Selection(selection.active, activePos);
}
return helper.selectionBetween(oldActive, newActive);
};
}
/**
* Create a SelectionMapper that jump / extend the selection to a new position
* (caret or coordinate, depending on `this.selectionBehavior`).
*
* @summary The mapper created is useful for commands that "jump" to a place
* without dragging to it, such as `gg` (gotoFirstLine) or arrows (`hjkl`).
*
* When `this.selectionBehavior` is Caret, the new active will be at the caret
* at the result Position. When not extending, the anchor is moved there as
* well, creating an empty selection on that spot.
*
* When `this.selectionBehavior` is Character and extending, new active always
* include the symbol at Position. When not extending, the new selection will
* contain exactly one symbol under the result Position.
*
* @param activeMapper a function that takes an old active coordinate and return
* the caret / character move to or `RemoveSelection`.
* @param extend if Extend, the new selection will keep the anchor caret /
* character. Otherwise, it is anchored to old active.
* @returns a SelectionMapper that is suitable for SelectionHelper#mapEach
*/
export function jumpTo(activeMapper: CoordMapper, extend: ExtendBehavior): SelectionMapper {
return (selection, helper, i) => {
const oldActive = helper.activeCoord(selection);
const newActive = activeMapper(oldActive, helper, i);
if ("remove" in newActive) {
return RemoveSelection;
}
if (helper.selectionBehavior === SelectionBehavior.Caret) {
return new vscode.Selection(extend ? selection.anchor : newActive, newActive);
}
if (extend) {
return helper.extend(selection, newActive);
}
return helper.selectionBetween(newActive, newActive);
};
}
/**
* Create a SelectionMapper that change / extend the selection to a new
* character range (including starting and ending characters).
*
* @summary The mapper created is useful for commands that "seeks" to a range of
* characters and re-anchor, such as `w` (selectWord) or `m` (selectMatching).
*
* The seek function returns two Coords, start and end, both inclusive. If not
* extending, a new selection will be created that includes both symbols under
* start and end. (The selection will be reversed if start is after end). When
* extending, start is ignored and the old extension is extended to sweep over
* the end symbol, selecting or deselecting it as appropriate.
*
* @param seekFunc a function that takes an old active coordinate and return
* the range ([start, end]) to seek to, or remove.
* @param extend if Extend, the new selection will keep the anchor caret /
* character. Otherwise, the new selection is anchored to start.
* @param singleCharDirection if specified, caret-based selection that only has
* one character will face that direction
* @returns a SelectionMapper that is suitable for SelectionHelper#mapEach
*/
export function seekToRange(
seek: SeekFunc,
extend: ExtendBehavior,
singleCharDirection = Forward,
): SelectionMapper {
return (selection, helper, i) => {
const oldActive = helper.activeCoord(selection);
const seekResult = seek(oldActive, helper, i);
let remove = false;
let start: Coord | undefined, end: Coord;
if ("remove" in seekResult) {
if (!seekResult.fallback) {
return RemoveSelection;
}
// Avoid array destructuring which is not fully optimized yet in V8.
// See: http://bit.ly/array-destructuring-for-multi-value-returns
start = seekResult.fallback[0];
end = seekResult.fallback[1];
remove = true;
} else {
start = seekResult[0];
end = seekResult[1];
}
let newSelection;
if (!start || extend) {
newSelection = helper.extend(selection, end);
} else if (helper.selectionBehavior === SelectionBehavior.Caret) {
newSelection = helper.selectionBetween(start, end, singleCharDirection);
} else {
newSelection = helper.selectionBetween(start, end);
}
if (remove) {
return { remove, fallback: newSelection };
} else {
return newSelection;
}
};
}

View File

@ -0,0 +1,71 @@
import * as vscode from "vscode";
/**
* A class used to validate settings.
*/
export class SettingsValidator {
public readonly path: string[] = [];
public readonly errors: string[] = [];
public constructor(...path: string[]) {
this.path.push(...path);
}
public enter(property: string) {
this.path.push(property);
}
public leave() {
this.path.pop();
}
public forProperty<T>(property: string, f: (validator: this) => T) {
this.enter(property);
try {
return f(this);
} finally {
this.leave();
}
}
public reportInvalidSetting(message: string, name?: string) {
const suffix = name === undefined ? "" : "." + name;
this.errors.push(`${this.path.join(".")}${suffix}: ${message}`);
}
public displayErrorIfNeeded() {
const errors = this.errors;
if (errors.length === 0) {
return;
}
return vscode.window.showErrorMessage("Invalid settings: " + errors.join(" — "));
}
public throwErrorIfNeeded() {
const errors = this.errors;
if (errors.length === 0) {
return;
}
throw new Error("Invalid settings: " + errors.join(" — "));
}
public static displayErrorIfNeeded<T>(path: string, f: (validator: SettingsValidator) => T) {
const validator = new SettingsValidator(path),
result = f(validator);
validator.displayErrorIfNeeded();
return result;
}
public static throwErrorIfNeeded<T>(path: string, f: (validator: SettingsValidator) => T) {
const validator = new SettingsValidator(path),
result = f(validator);
validator.throwErrorIfNeeded();
return result;
}
}

View File

@ -0,0 +1,331 @@
import * as vscode from "vscode";
import { ArgumentError } from "../api";
import { PerEditorState } from "../state/editors";
export namespace TrackedSelection {
/**
* Flags passed to `TrackedSelection.updateAfterDocumentChanged`.
*/
export const enum Flags {
Inclusive = 0,
StrictStart = 0b01,
StrictEnd = 0b10,
Strict = 0b11,
}
/**
* An array of tracked selections selections.
*/
export interface Array extends Iterable<number> {
[index: number]: number;
readonly length: number;
}
/**
* Creates a new `TrackedSelection.Array`, reading offsets from the given
* selections in the given document.
*/
export function fromArray(
selections: readonly vscode.Selection[],
document: vscode.TextDocument,
): Array {
const trackedSelections = [] as number[];
for (let i = 0, len = selections.length; i < len; i++) {
const selection = selections[i],
anchor = selection.anchor,
active = selection.active;
if (anchor.line === active.line) {
const anchorOffset = document.offsetAt(anchor),
activeOffset = anchorOffset + active.character - anchor.character;
trackedSelections.push(anchorOffset, activeOffset);
} else {
trackedSelections.push(document.offsetAt(anchor), document.offsetAt(active));
}
}
return trackedSelections;
}
export function restore(array: Array, index: number, document: vscode.TextDocument) {
const anchor = document.positionAt(array[index << 1]),
active = document.positionAt(array[(index << 1) | 1]);
return new vscode.Selection(anchor, active);
}
export function anchorOffset(array: Array, index: number) {
return array[index << 1];
}
export function activeOffset(array: Array, index: number) {
return array[(index << 1) | 1];
}
export function startOffset(array: Array, index: number) {
return Math.min(anchorOffset(array, index), activeOffset(array, index));
}
export function endOffset(array: Array, index: number) {
return Math.max(anchorOffset(array, index), activeOffset(array, index));
}
export function length(array: Array, index: number) {
return Math.abs(anchorOffset(array, index) - activeOffset(array, index));
}
export function setAnchorOffset(array: Array, index: number, offset: number) {
array[index << 1] = offset;
}
export function setActiveOffset(array: Array, index: number, offset: number) {
array[(index << 1) | 1] = offset;
}
export function activeIsStart(array: Array, index: number) {
return activeOffset(array, index) <= anchorOffset(array, index);
}
export function setLength(array: Array, index: number, length: number) {
const active = activeOffset(array, index),
anchor = anchorOffset(array, index);
if (active < anchor) {
setAnchorOffset(array, index, active + length);
} else {
setActiveOffset(array, index, anchor + length);
}
}
export function setStartEnd(
array: Array,
index: number,
startOffset: number,
endOffset: number,
startIsActive: boolean,
) {
if (startIsActive) {
setActiveOffset(array, index, startOffset);
setAnchorOffset(array, index, endOffset);
} else {
setActiveOffset(array, index, endOffset);
setAnchorOffset(array, index, startOffset);
}
}
/**
* Returns the saved selections, restored in the given document.
*/
export function restoreArray(array: Array, document: vscode.TextDocument) {
const selections = [] as vscode.Selection[];
for (let i = 0, len = array.length >> 1; i < len; i++) {
selections.push(restore(array, i, document));
}
return selections;
}
/**
* Returns the saved selections, restored in the given document, skipping
* empty selections.
*/
export function restoreNonEmpty(array: Array, document: vscode.TextDocument) {
const selections = [] as vscode.Selection[];
for (let i = 0, len = array.length >> 1; i < len; i++) {
const anchorOffset = array[i << 1],
activeOffset = array[(i << 1) | 1];
if (anchorOffset === activeOffset) {
continue;
}
selections.push(
new vscode.Selection(document.positionAt(anchorOffset), document.positionAt(activeOffset)),
);
}
return selections;
}
/**
* Updates the underlying selection to reflect a change in its document.
*/
export function updateAfterDocumentChanged(
array: Array,
changes: readonly vscode.TextDocumentContentChangeEvent[],
flags: TrackedSelection.Flags,
) {
for (let i = 0, len = array.length; i < len; i += 2) {
let anchorOffset = array[i],
activeOffset = array[i + 1];
const activeIsStart = activeOffset <= anchorOffset,
anchorIsStart = activeOffset >= anchorOffset,
inclusiveStart = (flags & Flags.StrictStart) === 0,
inclusiveEnd = (flags & Flags.StrictEnd) === 0,
inclusiveActive = activeIsStart ? !inclusiveStart : inclusiveEnd,
inclusiveAnchor = anchorIsStart ? !inclusiveStart : inclusiveEnd;
for (let i = 0, len = changes.length; i < len; i++) {
const change = changes[i],
diff = change.text.length - change.rangeLength,
offset = change.rangeOffset + change.rangeLength;
if (offset < activeOffset || (inclusiveActive && offset === activeOffset)) {
activeOffset += diff;
}
if (offset < anchorOffset || (inclusiveAnchor && offset === anchorOffset)) {
anchorOffset += diff;
}
}
array[i] = anchorOffset;
array[i + 1] = activeOffset;
}
}
/**
* A set of `TrackedSelection`s.
*/
export class Set implements vscode.Disposable {
private readonly _onDisposed = new vscode.EventEmitter<this>();
private readonly _selections: Array;
private readonly _onDidChangeTextDocumentSubscription: vscode.Disposable;
public get onDisposed() {
return this._onDisposed.event;
}
public constructor(
selections: Array,
public readonly document: vscode.TextDocument,
public readonly flags = Flags.Inclusive,
) {
ArgumentError.validate("selections", selections.length > 0, "selections cannot be empty");
this._selections = selections;
this._onDidChangeTextDocumentSubscription =
vscode.workspace.onDidChangeTextDocument(this.updateAfterDocumentChanged, this);
}
public addArray(array: Array) {
(this._selections as number[]).push(...array);
return this;
}
public addSelections(selections: readonly vscode.Selection[]) {
return this.addArray(TrackedSelection.fromArray(selections, this.document));
}
public addSelection(selection: vscode.Selection) {
return this.addArray(TrackedSelection.fromArray([selection], this.document));
}
/**
* Updates the tracked selections to reflect a change in their document.
*
* @returns whether the change was applied.
*/
protected updateAfterDocumentChanged(e: vscode.TextDocumentChangeEvent) {
if (e.document !== this.document || e.contentChanges.length === 0) {
return false;
}
updateAfterDocumentChanged(this._selections, e.contentChanges, this.flags);
return true;
}
public restore() {
return restoreArray(this._selections, this.document);
}
public restoreNonEmpty() {
return restoreNonEmpty(this._selections, this.document);
}
public dispose() {
this._onDidChangeTextDocumentSubscription.dispose();
}
}
/**
* A `TrackedSelection.Set` that displays active selections using some given
* style.
*/
export class StyledSet extends Set {
private readonly _decorationType: vscode.TextEditorDecorationType;
private readonly _onDidEditorVisibilityChangeSubscription: vscode.Disposable;
public constructor(
selections: Array,
public readonly editorState: PerEditorState,
renderOptions: vscode.DecorationRenderOptions,
) {
super(
selections, editorState.editor.document, rangeBehaviorToFlags(renderOptions.rangeBehavior));
this._decorationType = vscode.window.createTextEditorDecorationType(renderOptions);
this._onDidEditorVisibilityChangeSubscription = editorState.onVisibilityDidChange((e) =>
e.isVisible && this._updateDecorations());
this._updateDecorations();
}
public addArray(selections: Array) {
super.addArray(selections);
for (let i = 0, len = selections.length; i < len; i += 2) {
if (selections[i] !== selections[i + 1]) {
this._updateDecorations();
break;
}
}
return this;
}
protected updateAfterDocumentChanged(e: vscode.TextDocumentChangeEvent) {
if (!super.updateAfterDocumentChanged(e)) {
return false;
}
this._updateDecorations();
return true;
}
public dispose() {
super.dispose();
this._decorationType.dispose();
}
private _updateDecorations() {
this.editorState.editor.setDecorations(this._decorationType, this.restoreNonEmpty());
}
}
}
function rangeBehaviorToFlags(rangeBehavior: vscode.DecorationRangeBehavior | undefined) {
switch (rangeBehavior) {
case vscode.DecorationRangeBehavior.ClosedOpen:
return TrackedSelection.Flags.StrictStart;
case vscode.DecorationRangeBehavior.OpenClosed:
return TrackedSelection.Flags.StrictEnd;
case vscode.DecorationRangeBehavior.ClosedClosed:
return TrackedSelection.Flags.Strict;
default:
return TrackedSelection.Flags.Inclusive;
}
}

View File

@ -1,75 +1,282 @@
# Writing command tests
# Dance tests
Command tests are plain text files that are separated in several sections.
There are currently two types of tests for Dance: [API tests](#api-tests) and
[command tests](#command-tests). Both of these tests use code to represent
what documents should look like before and after performing changes, as well as
what selections should be on the given document. This syntax is specified [at
the end of this document](#syntax-of-expected-documents).
### Sections
## API tests
Each section has a name, which is any string that has no whitespace.
API tests are processed by [`api.test.ts`](./suite/api.test.ts), which reads
all files under the [`api`](../src/api) directory and (roughly) parses their
documentation comments. When these comments specify examples, these examples
are tested.
Except for the first section (implicitly named `0` or `root`), each section
is associated with some transition that consists of several Dance commands to run.
These examples are specified in Markdown, and must have a format matching the
following example.
For instance, let's look at the following code:
<details>
<summary><b>Example</b> with "before" / "after" sections</summary>
```
...
### Example
//== 0 > 1
//= dance.select.line
...
```js
const anchor = new vscode.Position(0, 0),
active = new vscode.Position(0, 3),
selection = new vscode.Selection(anchor, active);
//== 1 > 2
//= dance.select.line.extend
...
//== 1 > 3
//= dance.select.line
//= dance.select.line.extend
...
await updateSelections([selection]);
```
It defines three sections:
- `1`, which is reached after executing `dance.select.line` from section `0`.
- `2`, which is reached after executing `dance.select.line.extend` from section `1`.
- `3`, which is reached after executing `dance.select.line` and then `dance.select.line.extend` from section `1`.
As you can see, several sections can depend on the same parent section. Do note that
sections must be defined in order; that is, a section `a` cannot depend on a section `b`
if section `b` is defined after `a`.
### Section content
Each section has content (the `...` in the example above). That content is plain text to which
one or more selections must be added using a `{...}` / `|{...}` syntax, where `...` is a number.
`{0}` represents the anchor of the 1st selection, and `|{2}` represents the active position of the 3rd selection.
Selections can be given in any order, but must be complete; that is, if a selection `3` is given, then the
selections `0`, `1`, and `2` must be defined at some point too. The anchor can be omitted, and will default to
the active position.
### Tests generation
For each transition, a test will be generated making sure that executing the corresponding commands
will lead to some document with selections at some locations.
Let's look at the following code:
Before:
```
{0}f|{0}oo
//== 0 > 1
//= dance.right
f{0}o|{0}o
//== 1 > 2
//= dance.delete.yank
f{0}o|{0}
foo bar
^^^ 0
```
The first generated test asserts that calling `dance.right` in the document `foo` where `f` is the main selection
leads to a document `foo` with the first `o` selected.
After:
```
foo bar
^^^ 0
```
</details>
The second generated test asserts that calling `dance.delete.yank` in the document `foo` where the first `o` is
the main selection leads to a document `fo` with `o` selected.
<details>
<summary><b>Example</b> with "with" section</summary>
### Example
```js
expect(
text(Selections.current[0]),
"to be",
"bar",
);
```
With:
```
foo bar
^^^ 0
```
</details>
<details>
<summary><b>Example</b> with no section</summary>
### Example
```js
expect(
map(
new vscode.Range(Positions.at(0, 0), Positions.at(0, 5)),
(p) => p.translate(1),
),
"to satisfy",
{
start: expect.it("to be at coords", 0, 0),
end: expect.it("to be at coords", 0, 5),
},
)
```
</details>
### Debugging
While viewing a file in the [`api`](../src/api) directory, select "Run tests in
this file" to debug all doc tests in the file being viewed. Select "Run test on
this line" to debug the test of the function under the cursor.
## Command tests
Command tests are processed by [`commands.test.ts`](./suite/commands.test.ts),
which reads all files under the [`commands`](./suite/commands) directory and
parses them into several sections.
Each section must start with a Markdown `# heading`, which represents the title
of the section. Except for initial sections, all sections must also link to
the section from which they transition with a Markdown `[link](#heading)`.
Sections can then specify a set of [flags](#available-flags) that may alter the
behavior of their test using Markdown `> quotes`.
Then, the commands to execute are specified as Markdown `- lists`. These
commands may specify arguments in JSON.
Finally, the expected document after changes can be specified in a Markdown
code block.
<details>
<summary><b>Example</b></summary>
# 1
```
foo bar
^ 0
```
## 1 search-b
[up](#1)
- .search { input: "b" }
```
foo bar
^ 0
```
</details>
### Available flags
- `debug`: Inserts a `debugger` statement at the beginning of the test.
- `behavior <- character`: Sets selection behavior of normal mode to `character`
for the duration of the test. The mode will be reset to `caret` at the end of
the test. Tests that depend on a test with character behavior `character` will
default to having that same behavior. Use `behavior <- caret` to undo this.
- `/<pattern>/<replacement>/<flags>`: Replaces the given pattern by the given
replacement string in all sections that inherit from the current section.
When executing Dance commands, you may also pass an `$expect` argument with a
`RegExp` to check that the error throws an error whose message matches the
regular expression.
### Naming and organization
To make it easier to navigate and understand tests, tests must have be named
this way:
- Initial sections should be top-level headers (have a single `#` character),
and be a single non-whitespace word (e.g. `1`, `empty-document`).
- Non-initial sections have names split in several parts separated by spaces.
If a test essentially moves to the left, it should be named `<up> left`, with
`<up>` the name of the section from which it transitions. It should also have
as many `#` characters as parts (with a upper bound of 6).
* Names cannot contain whitespace. Names should be in snake-case. If a count
is associated with the test, it should be the last part of the test. If a
test repeats exactly what its previous test does, it should be named `x`.
* If a test performs multiple logically different actions, they should be
separated by `-then-` in the title of the test.
Finally, sections should always be nested under the section from which they
transition.
### Debugging
While viewing a Markdown file in the [`commands`](./suite/commands) directory,
select "Run tests in this file" to debug all tests defined by the Markdown file.
Select "Run test on this line" to debug the test defined in the section under
the cursor.
## Syntax of expected documents
Expected documents are regular strings where some lines specify where
selections should be set. These selections are represented using carets,
numbers and pipes.
These specifiers are replaced by spaces in the output document; if a line is
empty after removing specifiers, it is completely erased.
By default, specifiers face forward (the anchor position comes before the
active position).
<details>
<summary><b>Examples</b></summary>
> The following examples are also tested in [`utils.test.ts`](
./suite/utils.test.ts).
1. Equivalent to [0:0 → 0:3]:
```
foo bar
^^^ 0
```
2. Equivalent to [0:0 → 0:3]:
```
foo bar
^^| 0
```
3. Equivalent to [0:3 → 0:0]:
```
foo bar
|^^ 0
```
4. Equivalent to [0:0 → 0:3, 0:4 → 0:7]:
```
foo bar
^^^ 0
^^^ 1
```
5. Equivalent to [0:4 → 0:7, 0:0 → 0:3]:
```
foo bar
^^^ 1
^^^ 0
```
6. Equivalent to [0:0 → 0:1, 0:5 → 0:5]:
```
foo bar
^ 0 | 1
```
7. Equivalent to [0:0 → 2:4]:
```
foo
^ 0
bar
baz
^ 0
```
8. Equivalent to [0:0 → 2:4]:
```
foo
^ 0
bar
baz
| 0
```
9. Equivalent to [2:4 → 0:0]:
```
foo
| 0
bar
baz
^ 0
```
10. Equivalent to [2:4 → 0:0]:
```
foo
|^^ 0
bar
baz
^^^^ 0
```
11. Equivalent to [0:0 → 1:4]:
```
^ 0
abcd
^ 0
```
12. Equivalent to [0:3 → 0:3]:
```
foo
| 0
bar
```
13. Equivalent to [0:0 → 1:0, 1:0 → 1:3]:
```
abc
^^^^ 0
def
^^^ 1
```
14. Equivalent to [1:2 → 0:3]:
```
abc
| 0
def
^^ 0
```
</details>

View File

@ -0,0 +1,92 @@
import * as assert from "assert";
import { Builder, unindent } from "../../meta";
import { stringifyExpectedDocument } from "./build-utils";
export async function build(builder: Builder) {
const modules = await builder.getApiModules();
return modules.map((module) => {
const examples = module.functions.flatMap((f) => f.examples.map((example) => {
const match = exampleRe.exec(example);
assert(match !== null, "Example does not have valid format");
const [_, flags, code, before, after] = match;
return {
functionName: f.name,
flags,
code,
before,
after,
} as Example;
}));
if (examples.length === 0) {
return "";
}
const examplesPerFunction = new Map<string, number>();
// Note: we use the name starting with ./ below to make sure the filename is
// clickeable.
return unindent(4, `
suite(${JSON.stringify(module.path.replace(/^dance/, "."))}, function () {
${examples.map((example) => {
let testName = `function ${example.functionName}`;
const examplesForFunction = examplesPerFunction.get(example.functionName);
if (examplesForFunction === undefined) {
examplesPerFunction.set(example.functionName, 1);
} else {
testName += `#${examplesForFunction}`;
examplesPerFunction.set(example.functionName, examplesForFunction + 1);
}
const decls = [
"editorState = extension.editors.getState(editor)!",
"context = new Context(editorState, cancellationToken)",
];
for (const name of ["before", "after"] as const) {
const code = example[name];
if (code !== undefined) {
decls.push(`${name} = ExpectedDocument.parseIndented(${
stringifyExpectedDocument(code, 22, 14)})`);
}
}
return unindent(4, `
test("${testName}", async function () {
const ${decls.join(",\n ")};
${example.before !== undefined
? "await before.apply(editor);"
: "// No setup needed."}
await context.runAsync(async () => {${"\n"
+ example.code.replace(/^/gm, " ".repeat(16)).trimEnd()}
});
${example.after !== undefined
? "after.assertEquals(editor);"
: "// No expected end document."}
});
`);
}).join("")}
});
`);
}).join("") + "});\n";
}
interface Example {
functionName: string;
flags: string;
code: string;
before?: string;
after?: string;
}
const exampleRe = /^```(.+)\n([\s\S]+?)^```\n+(?:^(?:Before|With):\n^```\n([\s\S]*?)^```\n+(?:^After:\n^```\n([\s\S]+?)^```)?)?/m;

1353
test/suite/api.test.ts Normal file

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More