mirror of
https://github.com/pulsar-edit/pulsar.git
synced 2024-10-26 11:08:40 +03:00
Merge pull request #993 from pulsar-edit/bundle-snippets
Bundle snippets
This commit is contained in:
commit
6c1f1ad697
@ -159,7 +159,7 @@
|
|||||||
"service-hub": "^0.7.4",
|
"service-hub": "^0.7.4",
|
||||||
"settings-view": "file:packages/settings-view",
|
"settings-view": "file:packages/settings-view",
|
||||||
"sinon": "9.2.1",
|
"sinon": "9.2.1",
|
||||||
"snippets": "github:pulsar-edit/snippets#v1.8.0",
|
"snippets": "file:./packages/snippets",
|
||||||
"solarized-dark-syntax": "file:packages/solarized-dark-syntax",
|
"solarized-dark-syntax": "file:packages/solarized-dark-syntax",
|
||||||
"solarized-light-syntax": "file:packages/solarized-light-syntax",
|
"solarized-light-syntax": "file:packages/solarized-light-syntax",
|
||||||
"spell-check": "file:packages/spell-check",
|
"spell-check": "file:packages/spell-check",
|
||||||
@ -235,7 +235,7 @@
|
|||||||
"package-generator": "file:./packages/package-generator",
|
"package-generator": "file:./packages/package-generator",
|
||||||
"pulsar-updater": "file:./packages/pulsar-updater",
|
"pulsar-updater": "file:./packages/pulsar-updater",
|
||||||
"settings-view": "file:./packages/settings-view",
|
"settings-view": "file:./packages/settings-view",
|
||||||
"snippets": "1.8.0",
|
"snippets": "file:./packages/snippets",
|
||||||
"spell-check": "file:./packages/spell-check",
|
"spell-check": "file:./packages/spell-check",
|
||||||
"status-bar": "file:./packages/status-bar",
|
"status-bar": "file:./packages/status-bar",
|
||||||
"styleguide": "file:./packages/styleguide",
|
"styleguide": "file:./packages/styleguide",
|
||||||
|
@ -86,13 +86,15 @@ See [RFC 003](https://github.com/atom/atom/blob/master/docs/rfcs/003-consolidate
|
|||||||
| **settings-view** | [`./settings-view`](./settings-view) | |
|
| **settings-view** | [`./settings-view`](./settings-view) | |
|
||||||
| **package-generator** | [`./package-generator`](./package-generator) | |
|
| **package-generator** | [`./package-generator`](./package-generator) | |
|
||||||
| **pulsar-updater** | [`./pulsar-updater`](./pulsar-updater) | |
|
| **pulsar-updater** | [`./pulsar-updater`](./pulsar-updater) | |
|
||||||
| **snippets** | [`pulsar-edit/snippets`][snippets] | |
|
| **snippets** | [`./snippets`](./snippets) | |
|
||||||
| **solarized-dark-syntax** | [`./solarized-dark-syntax`](./solarized-dark-syntax) | |
|
| **solarized-dark-syntax** | [`./solarized-dark-syntax`](./solarized-dark-syntax) | |
|
||||||
| **solarized-light-syntax** | [`./solarized-light-syntax`](./solarized-light-syntax) | |
|
| **solarized-light-syntax** | [`./solarized-light-syntax`](./solarized-light-syntax) | |
|
||||||
| **spell-check** | [`./spell-check`](./spell-check) | |
|
| **spell-check** | [`./spell-check`](./spell-check) | |
|
||||||
| **status-bar** | [`./status-bar`](./status-bar) | |
|
| **status-bar** | [`./status-bar`](./status-bar) | |
|
||||||
|
| **symbol-provider-ctags** | [`./symbol-provider-ctags`](./symbol-provider-ctags) | |
|
||||||
|
| **symbol-provider-tree-sitter** | [`./symbol-provider-tree-sitter`](./symbol-provider-tree-sitter) | |
|
||||||
| **styleguide** | [`./styleguide`](./styleguide) | |
|
| **styleguide** | [`./styleguide`](./styleguide) | |
|
||||||
| **symbols-view** | [`pulsar-edit/symbols-view`][symbols-view] | |
|
| **symbols-view** | [`./symbols-view`](./symbols-view) | |
|
||||||
| **tabs** | [`./tabs`](./tabs) | |
|
| **tabs** | [`./tabs`](./tabs) | |
|
||||||
| **timecop** | [`./timecop`](./timecop) | |
|
| **timecop** | [`./timecop`](./timecop) | |
|
||||||
| **tree-view** | [`./tree-view`](./tree-view) | |
|
| **tree-view** | [`./tree-view`](./tree-view) | |
|
||||||
@ -102,5 +104,3 @@ See [RFC 003](https://github.com/atom/atom/blob/master/docs/rfcs/003-consolidate
|
|||||||
| **wrap-guide** | [`./wrap-guide`](./wrap-guide) | |
|
| **wrap-guide** | [`./wrap-guide`](./wrap-guide) | |
|
||||||
|
|
||||||
[github]: https://github.com/pulsar-edit/github
|
[github]: https://github.com/pulsar-edit/github
|
||||||
[snippets]: https://github.com/pulsar-edit/snippets
|
|
||||||
[symbols-view]: https://github.com/pulsar-edit/symbols-view
|
|
||||||
|
1
packages/snippets/.eslintignore
Normal file
1
packages/snippets/.eslintignore
Normal file
@ -0,0 +1 @@
|
|||||||
|
*.pegjs
|
13
packages/snippets/.eslintrc
Normal file
13
packages/snippets/.eslintrc
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"parserOptions": {
|
||||||
|
"sourceType": "module",
|
||||||
|
"ecmaVersion": 2022
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"indent": ["error", 2],
|
||||||
|
"linebreak-style": ["error", "unix"],
|
||||||
|
"object-curly-spacing": ["error", "never"],
|
||||||
|
"space-before-function-paren": ["error", "always"],
|
||||||
|
"semi": ["error", "never"]
|
||||||
|
}
|
||||||
|
}
|
2
packages/snippets/.gitignore
vendored
Normal file
2
packages/snippets/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
.tool-versions
|
16
packages/snippets/.pairs
Normal file
16
packages/snippets/.pairs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
pairs:
|
||||||
|
ns: Nathan Sobo; nathan
|
||||||
|
cj: Corey Johnson; cj
|
||||||
|
dg: David Graham; dgraham
|
||||||
|
ks: Kevin Sawicki; kevin
|
||||||
|
jc: Jerry Cheung; jerry
|
||||||
|
bl: Brian Lopez; brian
|
||||||
|
jp: Justin Palmer; justin
|
||||||
|
gt: Garen Torikian; garen
|
||||||
|
mc: Matt Colyer; mcolyer
|
||||||
|
bo: Ben Ogle; benogle
|
||||||
|
jr: Jason Rudolph; jasonrudolph
|
||||||
|
jl: Jessica Lord; jlord
|
||||||
|
email:
|
||||||
|
domain: github.com
|
||||||
|
#global: true
|
1
packages/snippets/CONTRIBUTING.md
Normal file
1
packages/snippets/CONTRIBUTING.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
[See how you can contribute](https://github.com/pulsar-edit/.github/blob/main/CONTRIBUTING.md)
|
208
packages/snippets/README.md
Normal file
208
packages/snippets/README.md
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
# Snippets package
|
||||||
|
|
||||||
|
Expand snippets matching the current prefix with <kbd>tab</kbd> in Pulsar.
|
||||||
|
|
||||||
|
To add your own snippets, select the _Pulsar > Snippets..._ menu option if you're using macOS, or the _File > Snippets..._ menu option if you're using Windows, or the _Edit > Snippets..._ menu option if you are using Linux.
|
||||||
|
|
||||||
|
## Snippet Format
|
||||||
|
|
||||||
|
Snippets files are stored in a package's `snippets/` folder and also loaded from `~/.pulsar/snippets.cson`. They can be either `.json` or `.cson` file types.
|
||||||
|
|
||||||
|
```coffee
|
||||||
|
'.source.js':
|
||||||
|
'console.log':
|
||||||
|
'prefix': 'log'
|
||||||
|
'command': 'insert-console-log'
|
||||||
|
'body': 'console.log(${1:"crash"});$2'
|
||||||
|
```
|
||||||
|
|
||||||
|
The outermost keys are the selectors where these snippets should be active, prefixed with a period (`.`) (details below).
|
||||||
|
|
||||||
|
The next level of keys are the snippet names. Because this is object notation, each snippet must have a different name.
|
||||||
|
|
||||||
|
Under each snippet name is a `body` to insert when the snippet is triggered.
|
||||||
|
|
||||||
|
`$` followed by a number are the tabs stops which can be cycled between by pressing <kbd>Tab</kbd> once a snippet has been triggered.
|
||||||
|
|
||||||
|
The above example adds a `console.log` snippet to JavaScript files that would expand to:
|
||||||
|
|
||||||
|
```js
|
||||||
|
console.log("crash");
|
||||||
|
```
|
||||||
|
|
||||||
|
The string `"crash"` would be initially selected and pressing tab again would place the cursor after the `;`
|
||||||
|
|
||||||
|
A snippet specifies how it can be triggered. Thus it must provide **at least one** of the following keys:
|
||||||
|
|
||||||
|
### The ‘prefix’ key
|
||||||
|
|
||||||
|
If a `prefix` is defined, it specifies a string that can trigger the snippet. In the above example, typing `log` (as its own word) and then pressing <kbd>Tab</kbd> would replace `log` with the string `console.log("crash")` as described above.
|
||||||
|
|
||||||
|
Prefix completions can be suggested if partially typed thanks to the `autocomplete-snippets` package.
|
||||||
|
|
||||||
|
### The ‘command’ key
|
||||||
|
|
||||||
|
If a `command` is defined, it specifies a command name that can trigger the snippet. That command can be invoked from the command palette or mapped to a keyboard shortcut via your `keymap.cson`.
|
||||||
|
|
||||||
|
If a package called `some-package` had defined that snippet, it would be available in the keymap as `some-package:insert-console-log`, or in the command palette as **Some Package: Insert Console Log**.
|
||||||
|
|
||||||
|
If you defined the `console.log` snippet described above in your own `snippets.cson`, it could be referenced in a keymap file as `snippets:insert-console-log`, or in the command palette as **Snippets: Insert Console Log**.
|
||||||
|
|
||||||
|
Invoking the command would insert the snippet at the cursor, replacing any text that may be selected.
|
||||||
|
|
||||||
|
Snippet command names must be unique. They can’t conflict with each other, nor can they conflict with any other commands that have been defined. If there is such a conflict, you’ll see an error notification describing the problem.
|
||||||
|
|
||||||
|
### Optional parameters
|
||||||
|
|
||||||
|
These parameters are meant to provide extra information about your snippet to [autocomplete-plus](https://github.com/atom/autocomplete-plus/wiki/Provider-API).
|
||||||
|
|
||||||
|
* `leftLabel` will add text to the left part of the autocomplete results box.
|
||||||
|
* `leftLabelHTML` will overwrite what's in `leftLabel` and allow you to use a bit of CSS such as `color`.
|
||||||
|
* `rightLabelHTML`. By default, in the right part of the results box you will see the name of the snippet. When using `rightLabelHTML` the name of the snippet will no longer be displayed, and you will be able to use a bit of CSS.
|
||||||
|
* `description` will add text to a description box under the autocomplete results list.
|
||||||
|
* `descriptionMoreURL` URL to the documentation of the snippet.
|
||||||
|
|
||||||
|
![autocomplete-description](http://i.imgur.com/cvI2lOq.png)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```coffee
|
||||||
|
'.source.js':
|
||||||
|
'console.log':
|
||||||
|
'prefix': 'log'
|
||||||
|
'body': 'console.log(${1:"crash"});$2'
|
||||||
|
'description': 'Output data to the console'
|
||||||
|
'rightLabelHTML': '<span style="color:#ff0">JS</span>'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Determining the correct scope for a snippet
|
||||||
|
|
||||||
|
The outmost key of a snippet is the “scope” that you want the descendent snippets to be available in. The key should be prefixed with a period (`text.html.basic` → `.text.html.basic`). You can find out the correct scope by opening the Settings (<kbd>cmd-,</kbd> on macOS) and selecting the corresponding *Language [xxx]* package. For example, here’s the settings page for `language-html`:
|
||||||
|
|
||||||
|
![Screenshot of Language Html settings](https://cloud.githubusercontent.com/assets/1038121/5137632/126beb66-70f2-11e4-839b-bc7e84103f67.png)
|
||||||
|
|
||||||
|
If it's difficult to determine the package handling the file type in question (for example, for `.md`-documents), you can use another approach:
|
||||||
|
|
||||||
|
1. Put your cursor in a file in which you want the snippet to be available.
|
||||||
|
2. Open the [Command Palette](https://github.com/pulsar-edit/command-palette)
|
||||||
|
(<kbd>cmd-shift-p</kbd> or <kbd>ctrl-shift-p</kbd>).
|
||||||
|
3. Run the `Editor: Log Cursor Scope` command.
|
||||||
|
|
||||||
|
This will trigger a notification which will contain a list of scopes. The first scope that's listed is the scope for that language. Here are some examples: `source.coffee`, `text.plain`, `text.html.basic`.
|
||||||
|
|
||||||
|
## Snippet syntax
|
||||||
|
|
||||||
|
This package supports a subset of the features of TextMate snippets, [documented here](http://manual.macromates.com/en/snippets), as well as most features described in the [LSP specification][lsp] and [supported by VSCode][vscode].
|
||||||
|
|
||||||
|
The following features from TextMate snippets are not yet supported:
|
||||||
|
|
||||||
|
* Interpolated shell code can’t reliably be supported cross-platform, and is probably a bad idea anyway. No other editors that support snippets have adopted this feature, and Pulsar won’t either.
|
||||||
|
|
||||||
|
The following features from VSCode snippets are not yet supported:
|
||||||
|
|
||||||
|
* “Choice” syntax like `${1|one,two,three|}` requires that the autocomplete engine pop up a menu to offer the user a choice between the available placeholder options. This may be supported in the future, but right now Pulsar effectively converts this to `${1:one}`, treating the first choice as a conventional placeholder.
|
||||||
|
|
||||||
|
### Variables
|
||||||
|
|
||||||
|
Pulsar snippets support all of the variables mentioned in the [LSP specification][lsp], plus many of the variables [supported by VSCode][vscode].
|
||||||
|
|
||||||
|
Variables can be referenced with `$`, either without braces (`$CLIPBOARD`) or with braces (`${CLIPBOARD}`). Variables can also have fallback values (`${CLIPBOARD:http://example.com}`), simple flag-based transformations (`${CLIPBOARD:/upcase}`), or `sed`-style transformations (`${CLIPBOARD/ /_/g}`).
|
||||||
|
|
||||||
|
One of the most useful is `TM_SELECTED_TEXT`, which represents whatever text was selected when the snippet was invoked. (Naturally, this can only happen when a snippet is invoked via command or key shortcut, rather than by typing in a <kbd>Tab</kbd> trigger.)
|
||||||
|
|
||||||
|
Others that can be useful:
|
||||||
|
|
||||||
|
* `TM_FILENAME`: The name of the current file (`foo.rb`).
|
||||||
|
* `TM_FILENAME_BASE`: The name of the current file, but without its extension (`foo`).
|
||||||
|
* `TM_FILEPATH`: The entire path on disk to the current file.
|
||||||
|
* `TM_CURRENT_LINE`: The entire current line that the cursor is sitting on.
|
||||||
|
* `TM_CURRENT_WORD`: The entire word that the cursor is within or adjacent to, as interpreted by `cursor.getCurrentWordBufferRange`.
|
||||||
|
* `CLIPBOARD`: The current contents of the clipboard.
|
||||||
|
* `CURRENT_YEAR`, `CURRENT_MONTH`, et cetera: referneces to the current date and time in various formats.
|
||||||
|
* `LINE_COMMENT`, `BLOCK_COMMENT_START`, `BLOCK_COMMENT_END`: uses the correct comment delimiters for whatever language you’re in.
|
||||||
|
|
||||||
|
Any variable that has no value — for instance, `TM_FILENAME` on an untitled document, or `LINE_COMMENT` in a CSS file — will resolve to an empty string.
|
||||||
|
|
||||||
|
#### Variable transformation flags
|
||||||
|
|
||||||
|
Pulsar supports the three flags defined in the [LSP snippets specification][lsp] and two other flags that are [implemented in VSCode][vscode]:
|
||||||
|
|
||||||
|
* `/upcase` (`foo` → `FOO`)
|
||||||
|
* `/downcase` (`BAR` → `bar`)
|
||||||
|
* `/capitalize` (`lorem ipsum dolor` → `Lorem ipsum dolor`) *(first letter uppercased; rest of input left intact)*
|
||||||
|
* `/camelcase` (`foo bar` → `fooBar`, `lorem-ipsum.dolor` → `loremIpsumDolor`)
|
||||||
|
* `/pascalcase` (`foo bar` → `FooBar`, `lorem-ipsum.dolor` → `LoremIpsumDolor`)
|
||||||
|
|
||||||
|
It also supports two other common transformations:
|
||||||
|
|
||||||
|
* `/snakecase` (`foo bar` → `foo_bar`, `lorem-ipsum.dolor` → `lorem_ipsum_dolor`)
|
||||||
|
* `/kebabcase` (`foo bar` → `foo-bar`, `lorem-ipsum.dolor` → `lorem-ipsum-dolor`)
|
||||||
|
|
||||||
|
These transformation flags can also be applied on backreferences in `sed`-style replacements for transformed tab stops. Given the following example snippet body…
|
||||||
|
|
||||||
|
```
|
||||||
|
[$1] becomes [${1/(.*)/${1:/upcase}/}]
|
||||||
|
```
|
||||||
|
|
||||||
|
…invoking the snippet and typing `Lorem ipsum dolor` will produce:
|
||||||
|
|
||||||
|
```
|
||||||
|
[Lorem ipsum dolor] becomes [LOREM IPSUM DOLOR]
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
#### Variable caveats
|
||||||
|
|
||||||
|
* `WORKSPACE_NAME`, `WORKSPACE_FOLDER`, and `RELATIVE_PATH` all rely on the presence of a root project folder, but a Pulsar project can technically have multiple root folders. While this is rare, it is handled by `snippets` as follows: whichever project path is an ancestor of the currently active file is treated as the project root — or the first one found if multiple roots are ancestors.
|
||||||
|
* `WORKSPACE_NAME` in VSCode refers to “the name of the opened workspace or folder.” In the former case, this appears to mean bundled projects with a `.code-workspace` file extension — which have no Pulsar equivalent. Instead, `WORKSPACE_NAME` will always refer to the last path component of your project’s root directory as defined above.
|
||||||
|
|
||||||
|
#### Variables that are not yet supported
|
||||||
|
|
||||||
|
Of the variables supported by VSCode, Pulsar does not yet support:
|
||||||
|
|
||||||
|
* `UUID` (Will automatically be supported when Pulsar uses a version of Electron that has native `crypto.randomUUID`.)
|
||||||
|
|
||||||
|
## Multi-line Snippet Body
|
||||||
|
|
||||||
|
You can also use multi-line syntax using `"""` for larger templates:
|
||||||
|
|
||||||
|
```coffee
|
||||||
|
'.source.js':
|
||||||
|
'if, else if, else':
|
||||||
|
'prefix': 'ieie'
|
||||||
|
'body': """
|
||||||
|
if (${1:true}) {
|
||||||
|
$2
|
||||||
|
} else if (${3:false}) {
|
||||||
|
$4
|
||||||
|
} else {
|
||||||
|
$5
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
## Escaping Characters
|
||||||
|
|
||||||
|
Including a literal closing brace inside the text provided by a snippet's tab stop will close that tab stop early. To prevent that, escape the brace with two backslashes, like so:
|
||||||
|
|
||||||
|
```coffee
|
||||||
|
'.source.js':
|
||||||
|
'function':
|
||||||
|
'prefix': 'funct'
|
||||||
|
'body': """
|
||||||
|
${1:function () {
|
||||||
|
statements;
|
||||||
|
\\}
|
||||||
|
this line is also included in the snippet tab;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
Likewise, if your snippet includes literal references to `$` or `{`, you may have to escape those with two backslashes as well, depending on the context.
|
||||||
|
|
||||||
|
## Multiple snippets for the same scope
|
||||||
|
|
||||||
|
Snippets for the same scope must be placed within the same key. See [this section of the Pulsar Flight Manual](https://pulsar-edit.dev/docs/launch-manual/sections/using-pulsar/#configuring-with-cson) for more information.
|
||||||
|
|
||||||
|
|
||||||
|
[lsp]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#variables
|
||||||
|
[vscode]: https://code.visualstudio.com/docs/editor/userdefinedsnippets#_variables
|
2
packages/snippets/keymaps/snippets-1.cson
Normal file
2
packages/snippets/keymaps/snippets-1.cson
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
'atom-text-editor:not([mini])':
|
||||||
|
'tab': 'snippets:expand'
|
6
packages/snippets/keymaps/snippets-2.cson
Normal file
6
packages/snippets/keymaps/snippets-2.cson
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
# it's critical that these bindings be loaded after those snippets-1 so they
|
||||||
|
# are later in the cascade, hence breaking the keymap into 2 files
|
||||||
|
|
||||||
|
'atom-text-editor:not([mini])':
|
||||||
|
'tab': 'snippets:next-tab-stop'
|
||||||
|
'shift-tab': 'snippets:previous-tab-stop'
|
76
packages/snippets/lib/editor-store.js
Normal file
76
packages/snippets/lib/editor-store.js
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
const SnippetHistoryProvider = require('./snippet-history-provider')
|
||||||
|
|
||||||
|
class EditorStore {
|
||||||
|
constructor (editor) {
|
||||||
|
this.editor = editor
|
||||||
|
this.buffer = this.editor.getBuffer()
|
||||||
|
this.observer = null
|
||||||
|
this.checkpoint = null
|
||||||
|
this.expansions = []
|
||||||
|
this.existingHistoryProvider = null
|
||||||
|
}
|
||||||
|
|
||||||
|
getExpansions () {
|
||||||
|
return this.expansions
|
||||||
|
}
|
||||||
|
|
||||||
|
setExpansions (list) {
|
||||||
|
this.expansions = list
|
||||||
|
}
|
||||||
|
|
||||||
|
clearExpansions () {
|
||||||
|
this.expansions = []
|
||||||
|
}
|
||||||
|
|
||||||
|
addExpansion (snippetExpansion) {
|
||||||
|
this.expansions.push(snippetExpansion)
|
||||||
|
}
|
||||||
|
|
||||||
|
observeHistory (delegates) {
|
||||||
|
let isObservingHistory = this.existingHistoryProvider != null
|
||||||
|
if (isObservingHistory) {
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
this.existingHistoryProvider = this.buffer.historyProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
const newProvider = SnippetHistoryProvider(this.existingHistoryProvider, delegates)
|
||||||
|
this.buffer.setHistoryProvider(newProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
stopObservingHistory (editor) {
|
||||||
|
if (this.existingHistoryProvider == null) { return }
|
||||||
|
this.buffer.setHistoryProvider(this.existingHistoryProvider)
|
||||||
|
this.existingHistoryProvider = null
|
||||||
|
}
|
||||||
|
|
||||||
|
observe (callback) {
|
||||||
|
if (this.observer != null) { this.observer.dispose() }
|
||||||
|
this.observer = this.buffer.onDidChangeText(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
stopObserving () {
|
||||||
|
if (this.observer == null) { return false }
|
||||||
|
this.observer.dispose()
|
||||||
|
this.observer = null
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
makeCheckpoint () {
|
||||||
|
const existing = this.checkpoint
|
||||||
|
if (existing) {
|
||||||
|
this.buffer.groupChangesSinceCheckpoint(existing)
|
||||||
|
}
|
||||||
|
this.checkpoint = this.buffer.createCheckpoint()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorStore.store = new WeakMap()
|
||||||
|
EditorStore.findOrCreate = function (editor) {
|
||||||
|
if (!this.store.has(editor)) {
|
||||||
|
this.store.set(editor, new EditorStore(editor))
|
||||||
|
}
|
||||||
|
return this.store.get(editor)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = EditorStore
|
13
packages/snippets/lib/helpers.js
Normal file
13
packages/snippets/lib/helpers.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
/** @babel */
|
||||||
|
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export function getPackageRoot() {
|
||||||
|
const {resourcePath} = atom.getLoadSettings()
|
||||||
|
const currentFileWasRequiredFromSnapshot = !path.isAbsolute(__dirname)
|
||||||
|
if (currentFileWasRequiredFromSnapshot) {
|
||||||
|
return path.join(resourcePath, 'node_modules', 'snippets')
|
||||||
|
} else {
|
||||||
|
return path.resolve(__dirname, '..')
|
||||||
|
}
|
||||||
|
}
|
31
packages/snippets/lib/insertion.js
Normal file
31
packages/snippets/lib/insertion.js
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
const Replacer = require('./replacer')
|
||||||
|
|
||||||
|
class Insertion {
|
||||||
|
constructor ({range, substitution, references}) {
|
||||||
|
this.range = range
|
||||||
|
this.substitution = substitution
|
||||||
|
this.references = references
|
||||||
|
if (substitution) {
|
||||||
|
if (substitution.replace === undefined) {
|
||||||
|
substitution.replace = ''
|
||||||
|
}
|
||||||
|
this.replacer = new Replacer(substitution.replace)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isTransformation () {
|
||||||
|
return !!this.substitution
|
||||||
|
}
|
||||||
|
|
||||||
|
transform (input) {
|
||||||
|
let {substitution} = this
|
||||||
|
if (!substitution) { return input }
|
||||||
|
this.replacer.resetFlags()
|
||||||
|
return input.replace(substitution.find, (...args) => {
|
||||||
|
let result = this.replacer.replace(...args)
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Insertion
|
107
packages/snippets/lib/replacer.js
Normal file
107
packages/snippets/lib/replacer.js
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
const FLAGS = require('./simple-transformations')
|
||||||
|
|
||||||
|
const ESCAPES = {
|
||||||
|
u: (flags) => {
|
||||||
|
flags.lowercaseNext = false
|
||||||
|
flags.uppercaseNext = true
|
||||||
|
},
|
||||||
|
l: (flags) => {
|
||||||
|
flags.uppercaseNext = false
|
||||||
|
flags.lowercaseNext = true
|
||||||
|
},
|
||||||
|
U: (flags) => {
|
||||||
|
flags.lowercaseAll = false
|
||||||
|
flags.uppercaseAll = true
|
||||||
|
},
|
||||||
|
L: (flags) => {
|
||||||
|
flags.uppercaseAll = false
|
||||||
|
flags.lowercaseAll = true
|
||||||
|
},
|
||||||
|
E: (flags) => {
|
||||||
|
flags.uppercaseAll = false
|
||||||
|
flags.lowercaseAll = false
|
||||||
|
},
|
||||||
|
r: (flags, result) => {
|
||||||
|
result.push('\\r')
|
||||||
|
},
|
||||||
|
n: (flags, result) => {
|
||||||
|
result.push('\\n')
|
||||||
|
},
|
||||||
|
$: (flags, result) => {
|
||||||
|
result.push('$')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformTextWithFlags (str, flags) {
|
||||||
|
if (flags.uppercaseAll) {
|
||||||
|
return str.toUpperCase()
|
||||||
|
} else if (flags.lowercaseAll) {
|
||||||
|
return str.toLowerCase()
|
||||||
|
} else if (flags.uppercaseNext) {
|
||||||
|
flags.uppercaseNext = false
|
||||||
|
return str.replace(/^./, s => s.toUpperCase())
|
||||||
|
} else if (flags.lowercaseNext) {
|
||||||
|
return str.replace(/^./, s => s.toLowerCase())
|
||||||
|
}
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// `Replacer` handles shared substitution semantics for tabstop and variable
|
||||||
|
// transformations.
|
||||||
|
class Replacer {
|
||||||
|
constructor (tokens) {
|
||||||
|
this.tokens = [...tokens]
|
||||||
|
this.resetFlags()
|
||||||
|
}
|
||||||
|
|
||||||
|
resetFlags () {
|
||||||
|
this.flags = {
|
||||||
|
uppercaseAll: false,
|
||||||
|
lowercaseAll: false,
|
||||||
|
uppercaseNext: false,
|
||||||
|
lowercaseNext: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
replace (...match) {
|
||||||
|
let result = []
|
||||||
|
|
||||||
|
function handleToken (token) {
|
||||||
|
if (typeof token === 'string') {
|
||||||
|
result.push(transformTextWithFlags(token, this.flags))
|
||||||
|
} else if (token.escape) {
|
||||||
|
ESCAPES[token.escape](this.flags, result)
|
||||||
|
} else if (token.backreference) {
|
||||||
|
if (token.transform && (token.transform in FLAGS)) {
|
||||||
|
let transformed = FLAGS[token.transform](match[token.backreference])
|
||||||
|
result.push(transformed)
|
||||||
|
} else {
|
||||||
|
let {iftext, elsetext} = token
|
||||||
|
if (iftext != null && elsetext != null) {
|
||||||
|
// If-else syntax makes choices based on the presence or absence of a
|
||||||
|
// capture group backreference.
|
||||||
|
let m = match[token.backreference]
|
||||||
|
let tokenToHandle = m ? iftext : elsetext
|
||||||
|
if (Array.isArray(tokenToHandle)) {
|
||||||
|
result.push(...tokenToHandle.map(handleToken.bind(this)))
|
||||||
|
} else {
|
||||||
|
result.push(handleToken.call(this, tokenToHandle))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let transformed = transformTextWithFlags(
|
||||||
|
match[token.backreference],
|
||||||
|
this.flags
|
||||||
|
)
|
||||||
|
result.push(transformed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tokens.forEach(handleToken.bind(this))
|
||||||
|
return result.join('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Replacer
|
47
packages/snippets/lib/simple-transformations.js
Normal file
47
packages/snippets/lib/simple-transformations.js
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
// Simple transformation flags that can convert a string in various ways. They
|
||||||
|
// are specified for variables and for transforming substitution
|
||||||
|
// backreferences, so we need to use them in two places.
|
||||||
|
const FLAGS = {
|
||||||
|
// These are included in the LSP spec.
|
||||||
|
upcase: value => (value || '').toLocaleUpperCase(),
|
||||||
|
downcase: value => (value || '').toLocaleLowerCase(),
|
||||||
|
capitalize: (value) => {
|
||||||
|
return !value ? '' : (value[0].toLocaleUpperCase() + value.substr(1))
|
||||||
|
},
|
||||||
|
|
||||||
|
// These are supported by VSCode.
|
||||||
|
pascalcase (value) {
|
||||||
|
const match = value.match(/[a-z0-9]+/gi)
|
||||||
|
if (!match) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return match.map(word => {
|
||||||
|
return word.charAt(0).toUpperCase() + word.substr(1)
|
||||||
|
}).join('')
|
||||||
|
},
|
||||||
|
camelcase (value) {
|
||||||
|
const match = value.match(/[a-z0-9]+/gi)
|
||||||
|
if (!match) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return match.map((word, index) => {
|
||||||
|
if (index === 0) {
|
||||||
|
return word.charAt(0).toLowerCase() + word.substr(1)
|
||||||
|
}
|
||||||
|
return word.charAt(0).toUpperCase() + word.substr(1)
|
||||||
|
}).join('')
|
||||||
|
},
|
||||||
|
|
||||||
|
// No reason not to implement these also.
|
||||||
|
snakecase (value) {
|
||||||
|
let camel = this.camelcase(value)
|
||||||
|
return camel.replace(/[A-Z]/g, (match) => `_${match.toLowerCase()}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
kebabcase (value) {
|
||||||
|
let camel = this.camelcase(value)
|
||||||
|
return camel.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = FLAGS
|
18
packages/snippets/lib/snippet-body-parser.js
Normal file
18
packages/snippets/lib/snippet-body-parser.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
let parser
|
||||||
|
try {
|
||||||
|
// When the .pegjs file is stable and you're ready for release, run `npx
|
||||||
|
// pegjs lib/snippet-body.pegjs` to compile the parser. That way end users
|
||||||
|
// won't have to pay the cost of runtime evaluation.
|
||||||
|
parser = require('./snippet-body')
|
||||||
|
} catch (error) {
|
||||||
|
// When you're iterating on the parser, rename or delete `snippet-body.js` so
|
||||||
|
// you can make changes to the .pegjs file and have them reflected after a
|
||||||
|
// window reload.
|
||||||
|
const fs = require('fs')
|
||||||
|
const PEG = require('pegjs')
|
||||||
|
|
||||||
|
const grammarSrc = fs.readFileSync(require.resolve('./snippet-body.pegjs'), 'utf8')
|
||||||
|
parser = PEG.generate(grammarSrc)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = parser
|
2948
packages/snippets/lib/snippet-body.js
Normal file
2948
packages/snippets/lib/snippet-body.js
Normal file
File diff suppressed because it is too large
Load Diff
231
packages/snippets/lib/snippet-body.pegjs
Normal file
231
packages/snippets/lib/snippet-body.pegjs
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
|
||||||
|
{
|
||||||
|
// If you're making changes to this file, be sure to re-compile afterward
|
||||||
|
// using the instructions in `snippet-body-parser.js`.
|
||||||
|
|
||||||
|
function makeInteger(i) {
|
||||||
|
return parseInt(i.join(''), 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function coalesce (parts) {
|
||||||
|
const result = [];
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
const part = parts[i];
|
||||||
|
const ri = result.length - 1;
|
||||||
|
if (typeof part === 'string' && typeof result[ri] === 'string') {
|
||||||
|
result[ri] = result[ri] + part;
|
||||||
|
} else {
|
||||||
|
result.push(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrap (val) {
|
||||||
|
let shouldUnwrap = Array.isArray(val) && val.length === 1 && typeof val[0] === 'string';
|
||||||
|
return shouldUnwrap ? val[0] : val;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyContent = content:(tabstop / choice / variable / text)* { return content; }
|
||||||
|
|
||||||
|
innerBodyContent = content:(tabstop / choice / variable / nonCloseBraceText)* { return content; }
|
||||||
|
|
||||||
|
tabstop = simpleTabstop / tabstopWithoutPlaceholder / tabstopWithPlaceholder / tabstopWithTransform
|
||||||
|
|
||||||
|
simpleTabstop = '$' index:int {
|
||||||
|
return {index: makeInteger(index), content: []}
|
||||||
|
}
|
||||||
|
|
||||||
|
tabstopWithoutPlaceholder = '${' index:int '}' {
|
||||||
|
return {index: makeInteger(index), content: []}
|
||||||
|
}
|
||||||
|
|
||||||
|
tabstopWithPlaceholder = '${' index:int ':' content:innerBodyContent '}' {
|
||||||
|
return {index: makeInteger(index), content: content}
|
||||||
|
}
|
||||||
|
|
||||||
|
tabstopWithTransform = '${' index:int substitution:transform '}' {
|
||||||
|
return {
|
||||||
|
index: makeInteger(index),
|
||||||
|
content: [],
|
||||||
|
substitution: substitution
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
choice = '${' index:int '|' choice:choicecontents '|}' {
|
||||||
|
// Choice syntax requires an autocompleter to offer the user the options. As
|
||||||
|
// a fallback, we can take the first option and treat it as a placeholder.
|
||||||
|
const content = choice.length > 0 ? [choice[0]] : []
|
||||||
|
return {index: makeInteger(index), choice: choice, content: content}
|
||||||
|
}
|
||||||
|
|
||||||
|
choicecontents = elem:choicetext rest:(',' val:choicetext { return val } )* {
|
||||||
|
return [elem, ...rest]
|
||||||
|
}
|
||||||
|
|
||||||
|
choicetext = choicetext:(choiceEscaped / [^|,] / barred:('|' &[^}]) { return barred.join('') } )+ {
|
||||||
|
return choicetext.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
transform = '/' regex:regexString '/' replace:replace '/' flags:flags {
|
||||||
|
return {find: new RegExp(regex, flags), replace: replace}
|
||||||
|
}
|
||||||
|
|
||||||
|
regexString = regex:(escaped / [^/])* {
|
||||||
|
return regex.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
replace = (format / replacetext)*
|
||||||
|
|
||||||
|
format = simpleFormat / formatWithoutPlaceholder / formatWithCaseTransform / formatWithIf / formatWithIfElse / formatWithElse / formatEscape / formatWithIfElseAlt / formatWithIfAlt
|
||||||
|
|
||||||
|
simpleFormat = '$' index:int {
|
||||||
|
return {backreference: makeInteger(index)}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatWithoutPlaceholder = '${' index:int '}' {
|
||||||
|
return {backreference: makeInteger(index)}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatWithCaseTransform = '${' index:int ':' caseTransform:caseTransform '}' {
|
||||||
|
return {backreference: makeInteger(index), transform: caseTransform}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatWithIf = '${' index:int ':+' iftext:(ifElseText / '') '}' {
|
||||||
|
return {backreference: makeInteger(index), iftext: unwrap(iftext), elsetext: ''}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatWithIfAlt = '(?' index:int ':' iftext:(ifTextAlt / '') ')' {
|
||||||
|
return {backreference: makeInteger(index), iftext: unwrap(iftext), elseText: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
formatWithElse = '${' index:int (':-' / ':') elsetext:(ifElseText / '') '}' {
|
||||||
|
return {backreference: makeInteger(index), iftext: '', elsetext: unwrap(elsetext)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variable interpolation if-else; conditional clause queries the presence of a
|
||||||
|
// specific tabstop value.
|
||||||
|
formatWithIfElse = '${' index:int ':?' iftext:ifText ':' elsetext:(ifElseText / '') '}' {
|
||||||
|
return {backreference: makeInteger(index), iftext: iftext, elsetext: elsetext}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Substitution if-else; conditional clause tests whether a given regex capture
|
||||||
|
// group matched anything.
|
||||||
|
formatWithIfElseAlt = '(?' index:int ':' iftext:(ifTextAlt / '') ':' elsetext:(elseTextAlt / '') ')' {
|
||||||
|
return {backreference: makeInteger(index), iftext: iftext, elsetext: elsetext}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonColonText = text:('\\:' { return ':' } / escaped / [^:])* {
|
||||||
|
return text.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
formatEscape = '\\' flag:[ULulErn] {
|
||||||
|
return {escape: flag}
|
||||||
|
}
|
||||||
|
|
||||||
|
caseTransform = '/' type:[a-zA-Z]* {
|
||||||
|
return type.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
replacetext = replacetext:(!formatEscape char:escaped { return char } / !format char:[^/] { return char })+ {
|
||||||
|
return replacetext.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
variable = simpleVariable / variableWithSimpleTransform / variableWithoutPlaceholder / variableWithPlaceholder / variableWithTransform
|
||||||
|
|
||||||
|
simpleVariable = '$' name:variableName {
|
||||||
|
return {variable: name}
|
||||||
|
}
|
||||||
|
|
||||||
|
variableWithoutPlaceholder = '${' name:variableName '}' {
|
||||||
|
return {variable: name}
|
||||||
|
}
|
||||||
|
|
||||||
|
variableWithPlaceholder = '${' name:variableName ':' content:innerBodyContent '}' {
|
||||||
|
return {variable: name, content: content}
|
||||||
|
}
|
||||||
|
|
||||||
|
variableWithTransform = '${' name:variableName substitution:transform '}' {
|
||||||
|
return {variable: name, substitution: substitution}
|
||||||
|
}
|
||||||
|
|
||||||
|
variableWithSimpleTransform = '${' name:variableName ':/' substitutionFlag:substitutionFlag '}' {
|
||||||
|
return {variable: name, substitution: {flag: substitutionFlag}}
|
||||||
|
}
|
||||||
|
|
||||||
|
variableName = first:[a-zA-Z_] rest:[a-zA-Z_0-9]* {
|
||||||
|
return first + rest.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
substitutionFlag = chars:[a-z]+ {
|
||||||
|
return chars.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
int = [0-9]+
|
||||||
|
|
||||||
|
escaped = '\\' char:. {
|
||||||
|
switch (char) {
|
||||||
|
case '$':
|
||||||
|
case '\\':
|
||||||
|
case ':':
|
||||||
|
case '\x7D': // back brace; PEGjs would treat it as the JS scope end though
|
||||||
|
return char
|
||||||
|
default:
|
||||||
|
return '\\' + char
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
choiceEscaped = '\\' char:. {
|
||||||
|
switch (char) {
|
||||||
|
case '$':
|
||||||
|
case '\\':
|
||||||
|
case '\x7D':
|
||||||
|
case '|':
|
||||||
|
case ',':
|
||||||
|
return char
|
||||||
|
default:
|
||||||
|
return '\\' + char
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flags = flags:[a-z]* {
|
||||||
|
return flags.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
text = text:(escaped / !tabstop !variable !choice char:. { return char })+ {
|
||||||
|
return text.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
nonCloseBraceText = text:(escaped / !tabstop !variable !choice char:[^}] { return char })+ {
|
||||||
|
return text.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two kinds of format string conditional syntax: the `${` flavor and the `(?`
|
||||||
|
// flavor.
|
||||||
|
//
|
||||||
|
// VSCode supports only the `${` flavor. It's easier to parse because the
|
||||||
|
// if-result and else-result can only be plain text, as per the specification.
|
||||||
|
//
|
||||||
|
// TextMate supports both. `(?` is more powerful, but also harder to parse,
|
||||||
|
// because it can contain special flags and regex backreferences.
|
||||||
|
|
||||||
|
// For the first part of a two-part if-else. Runs until the `:` delimiter.
|
||||||
|
ifText = text:(escaped / char:[^:] { return char })+ {
|
||||||
|
return text.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
// For either the second part of a two-part if-else OR the sole part of a
|
||||||
|
// one-part if/else. Runs until the `}` that ends the expression.
|
||||||
|
ifElseText = text:(escaped / char:[^}] { return char })+ {
|
||||||
|
return text.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
ifTextAlt = text:(formatEscape / format / escaped / char:[^:] { return char })+ {
|
||||||
|
return coalesce(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
elseTextAlt = text:(formatEscape / format / escaped / char:[^)] { return char })+ {
|
||||||
|
return coalesce(text);
|
||||||
|
}
|
496
packages/snippets/lib/snippet-expansion.js
Normal file
496
packages/snippets/lib/snippet-expansion.js
Normal file
@ -0,0 +1,496 @@
|
|||||||
|
const {CompositeDisposable, Range, Point} = require('atom')
|
||||||
|
|
||||||
|
module.exports = class SnippetExpansion {
|
||||||
|
constructor (snippet, editor, cursor, snippets, {method} = {}) {
|
||||||
|
this.settingTabStop = false
|
||||||
|
this.isIgnoringBufferChanges = false
|
||||||
|
this.onUndoOrRedo = this.onUndoOrRedo.bind(this)
|
||||||
|
this.snippet = snippet
|
||||||
|
this.editor = editor
|
||||||
|
this.cursor = cursor
|
||||||
|
this.snippets = snippets
|
||||||
|
this.subscriptions = new CompositeDisposable
|
||||||
|
this.selections = [this.cursor.selection]
|
||||||
|
|
||||||
|
// Method refers to how the snippet was invoked; known values are `prefix`
|
||||||
|
// or `command`. If neither is present, then snippet was inserted
|
||||||
|
// programmatically.
|
||||||
|
this.method = method
|
||||||
|
|
||||||
|
// Holds the `Insertion` instance corresponding to each tab stop marker. We
|
||||||
|
// don't use the tab stop's own numbering here; we renumber them
|
||||||
|
// consecutively starting at 0 in the order in which they should be
|
||||||
|
// visited. So `$1` (if present) will always be at index `0`, and `$0` (if
|
||||||
|
// present) will always be the last index.
|
||||||
|
this.insertionsByIndex = []
|
||||||
|
|
||||||
|
// Each insertion has a corresponding marker. We keep them in a map so we
|
||||||
|
// can easily reassociate an insertion with its new marker when we destroy
|
||||||
|
// its old one.
|
||||||
|
this.markersForInsertions = new Map()
|
||||||
|
|
||||||
|
this.resolutionsForVariables = new Map()
|
||||||
|
this.markersForVariables = new Map()
|
||||||
|
|
||||||
|
// The index of the active tab stop.
|
||||||
|
this.tabStopIndex = null
|
||||||
|
|
||||||
|
// If, say, tab stop 4's placeholder references tab stop 2, then tab stop
|
||||||
|
// 4's insertion goes into this map as a "related" insertion to tab stop 2.
|
||||||
|
// We need to keep track of this because tab stop 4's marker will need to
|
||||||
|
// be replaced while 2 is the active index.
|
||||||
|
this.relatedInsertionsByIndex = new Map()
|
||||||
|
|
||||||
|
const startPosition = this.cursor.selection.getBufferRange().start
|
||||||
|
let {body, tabStopList} = this.snippet
|
||||||
|
let tabStops = tabStopList.toArray()
|
||||||
|
|
||||||
|
let indent = this.editor.lineTextForBufferRow(startPosition.row).match(/^\s*/)[0]
|
||||||
|
if (this.snippet.lineCount > 1 && indent) {
|
||||||
|
// Add proper leading indentation to the snippet
|
||||||
|
body = body.replace(/\n/g, `\n${indent}`)
|
||||||
|
|
||||||
|
tabStops = tabStops.map(tabStop => tabStop.copyWithIndent(indent))
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ignoringBufferChanges(() => {
|
||||||
|
this.editor.transact(() => {
|
||||||
|
// Determine what each variable reference will be replaced by
|
||||||
|
// _before_ we make any changes to the state of the editor. This
|
||||||
|
// affects $TM_SELECTED_TEXT, $TM_CURRENT_WORD, and others.
|
||||||
|
this.resolveVariables(startPosition)
|
||||||
|
// Insert the snippet body at the cursor.
|
||||||
|
const newRange = this.cursor.selection.insertText(body, {autoIndent: false})
|
||||||
|
// Mark the range we just inserted. Once we interpolate variables and
|
||||||
|
// apply transformations, the range may grow, and we need to keep
|
||||||
|
// track of that so we can normalize tabs later on.
|
||||||
|
const newRangeMarker = this.getMarkerLayer(this.editor).markBufferRange(newRange, {exclusive: false})
|
||||||
|
|
||||||
|
if (this.snippet.tabStopList.length > 0) {
|
||||||
|
// Listen for cursor changes so we can decide whether to keep the
|
||||||
|
// snippet active or terminate it.
|
||||||
|
this.subscriptions.add(
|
||||||
|
this.cursor.onDidChangePosition(event => this.cursorMoved(event)),
|
||||||
|
this.cursor.onDidDestroy(() => this.cursorDestroyed())
|
||||||
|
)
|
||||||
|
// First we'll add display markers for tab stops and variables.
|
||||||
|
// Both need these areas to be marked before any expansion happens
|
||||||
|
// so that they don't lose track of where their slots are.
|
||||||
|
this.placeTabStopMarkers(startPosition, tabStops)
|
||||||
|
this.markVariables(startPosition)
|
||||||
|
|
||||||
|
// Now we'll expand variables. All markers in the previous step
|
||||||
|
// were defined with `exclusive: false`, so any that are affected
|
||||||
|
// by variable expansion will grow if necessary.
|
||||||
|
this.expandVariables(startPosition)
|
||||||
|
|
||||||
|
// Now we'll make the first tab stop active and apply snippet
|
||||||
|
// transformations for the first time. As part of this process,
|
||||||
|
// most markers will be converted to `exclusive: true` and adjusted
|
||||||
|
// as necessary as the user tabs through the snippet.
|
||||||
|
this.setTabStopIndex(0)
|
||||||
|
this.applyAllTransformations()
|
||||||
|
|
||||||
|
this.snippets.addExpansion(this.editor, this)
|
||||||
|
} else {
|
||||||
|
// No tab stops, so we're free to mark and expand variables without
|
||||||
|
// worrying about the delicate order of operations.
|
||||||
|
this.markVariables(startPosition)
|
||||||
|
this.expandVariables(startPosition)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snippet bodies are written generically and don't know anything
|
||||||
|
// about the user's indentation settings. So we adjust them after
|
||||||
|
// expansion.
|
||||||
|
this.editor.normalizeTabsInBufferRange(newRangeMarker.getBufferRange())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set a flag on undo or redo so that we know not to re-apply transforms.
|
||||||
|
// They're already accounted for in the history.
|
||||||
|
onUndoOrRedo (isUndo) {
|
||||||
|
this.isUndoingOrRedoing = true
|
||||||
|
}
|
||||||
|
|
||||||
|
cursorMoved ({oldBufferPosition, newBufferPosition, textChanged}) {
|
||||||
|
if (this.settingTabStop || textChanged) { return }
|
||||||
|
const insertionAtCursor = this.insertionsByIndex[this.tabStopIndex].find(insertion => {
|
||||||
|
let marker = this.markersForInsertions.get(insertion)
|
||||||
|
return marker.getBufferRange().containsPoint(newBufferPosition)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (insertionAtCursor && !insertionAtCursor.isTransformation()) { return }
|
||||||
|
|
||||||
|
this.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
cursorDestroyed () {
|
||||||
|
// The only time a cursor can be destroyed without it ending the snippet is
|
||||||
|
// if we move from a mirrored tab stop (i.e., multiple cursors) to a
|
||||||
|
// single-cursor tab stop.
|
||||||
|
if (!this.settingTabStop) { this.destroy() }
|
||||||
|
}
|
||||||
|
|
||||||
|
textChanged (event) {
|
||||||
|
if (this.isIgnoringBufferChanges) { return }
|
||||||
|
|
||||||
|
// Don't try to alter the buffer if all we're doing is restoring a snapshot
|
||||||
|
// from history.
|
||||||
|
if (this.isUndoingOrRedoing) {
|
||||||
|
this.isUndoingOrRedoing = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.applyTransformations(this.tabStopIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoringBufferChanges (callback) {
|
||||||
|
const wasIgnoringBufferChanges = this.isIgnoringBufferChanges
|
||||||
|
this.isIgnoringBufferChanges = true
|
||||||
|
callback()
|
||||||
|
this.isIgnoringBufferChanges = wasIgnoringBufferChanges
|
||||||
|
}
|
||||||
|
|
||||||
|
applyAllTransformations () {
|
||||||
|
this.editor.transact(() => {
|
||||||
|
this.insertionsByIndex.forEach((insertion, index) =>
|
||||||
|
this.applyTransformations(index))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTransformations (tabStopIndex) {
|
||||||
|
const insertions = [...this.insertionsByIndex[tabStopIndex]]
|
||||||
|
if (insertions.length === 0) { return }
|
||||||
|
|
||||||
|
const primaryInsertion = insertions.shift()
|
||||||
|
const primaryRange = this.markersForInsertions.get(primaryInsertion).getBufferRange()
|
||||||
|
const inputText = this.editor.getTextInBufferRange(primaryRange)
|
||||||
|
|
||||||
|
this.ignoringBufferChanges(() => {
|
||||||
|
for (const [index, insertion] of insertions.entries()) {
|
||||||
|
// Don't transform mirrored tab stops. They have their own cursors, so
|
||||||
|
// mirroring happens automatically.
|
||||||
|
if (!insertion.isTransformation()) { continue }
|
||||||
|
|
||||||
|
var marker = this.markersForInsertions.get(insertion)
|
||||||
|
var range = marker.getBufferRange()
|
||||||
|
|
||||||
|
var outputText = insertion.transform(inputText)
|
||||||
|
this.editor.transact(() => this.editor.setTextInBufferRange(range, outputText))
|
||||||
|
|
||||||
|
// Manually adjust the marker's range rather than rely on its internal
|
||||||
|
// heuristics. (We don't have to worry about whether it's been
|
||||||
|
// invalidated because setting its buffer range implicitly marks it as
|
||||||
|
// valid again.)
|
||||||
|
const newRange = new Range(
|
||||||
|
range.start,
|
||||||
|
range.start.traverse(new Point(0, outputText.length))
|
||||||
|
)
|
||||||
|
marker.setBufferRange(newRange)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveVariables (startPosition) {
|
||||||
|
let params = {
|
||||||
|
editor: this.editor,
|
||||||
|
cursor: this.cursor,
|
||||||
|
selectionRange: this.cursor.selection.getBufferRange(),
|
||||||
|
method: this.method
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const variable of this.snippet.variables) {
|
||||||
|
let resolution = variable.resolve(params)
|
||||||
|
this.resolutionsForVariables.set(variable, resolution)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
markVariables (startPosition) {
|
||||||
|
// We make two passes here. On the first pass, we create markers for each
|
||||||
|
// point where a variable will be inserted. On the second pass, we use each
|
||||||
|
// marker to insert the resolved variable value.
|
||||||
|
//
|
||||||
|
// Those points will move around as we insert text into them, so the
|
||||||
|
// markers are crucial for ensuring we adapt to those changes.
|
||||||
|
for (const variable of this.snippet.variables) {
|
||||||
|
const {point} = variable
|
||||||
|
const marker = this.getMarkerLayer(this.editor).markBufferRange([
|
||||||
|
startPosition.traverse(point),
|
||||||
|
startPosition.traverse(point)
|
||||||
|
], {exclusive: false})
|
||||||
|
this.markersForVariables.set(variable, marker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expandVariables (startPosition) {
|
||||||
|
this.editor.transact(() => {
|
||||||
|
for (const variable of this.snippet.variables) {
|
||||||
|
let marker = this.markersForVariables.get(variable)
|
||||||
|
let resolution = this.resolutionsForVariables.get(variable)
|
||||||
|
let range = marker.getBufferRange()
|
||||||
|
this.editor.setTextInBufferRange(range, resolution)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
placeTabStopMarkers (startPosition, tabStops) {
|
||||||
|
// Tab stops within a snippet refer to one another by their external index
|
||||||
|
// (1 for $1, 3 for $3, etc.). We respect the order of these tab stops, but
|
||||||
|
// we renumber them starting at 0 and using consecutive numbers.
|
||||||
|
//
|
||||||
|
// Luckily, we don't need to convert between the two numbering systems very
|
||||||
|
// often. But we do have to build a map from external index to our internal
|
||||||
|
// index. We do this in a separate loop so that the table is complete
|
||||||
|
// before we need to consult it in the following loop.
|
||||||
|
const indexTable = {}
|
||||||
|
for (let [index, tabStop] of tabStops.entries()) {
|
||||||
|
indexTable[tabStop.index] = index
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let [index, tabStop] of tabStops.entries()) {
|
||||||
|
const {insertions} = tabStop
|
||||||
|
|
||||||
|
if (!tabStop.isValid()) { continue }
|
||||||
|
|
||||||
|
for (const insertion of insertions) {
|
||||||
|
const {range} = insertion
|
||||||
|
const {start, end} = range
|
||||||
|
let references = null
|
||||||
|
if (insertion.references) {
|
||||||
|
references = insertion.references.map(external => indexTable[external])
|
||||||
|
}
|
||||||
|
// This is our initial pass at marking tab stop regions. In a minute,
|
||||||
|
// once the first tab stop is made active, we will make some of these
|
||||||
|
// markers exclusive and some inclusive. But right now we need them all
|
||||||
|
// to be inclusive, because we want them all to react when we resolve
|
||||||
|
// snippet variables, and grow if they need to.
|
||||||
|
const marker = this.getMarkerLayer(this.editor).markBufferRange([
|
||||||
|
startPosition.traverse(start),
|
||||||
|
startPosition.traverse(end)
|
||||||
|
], {exclusive: false})
|
||||||
|
// Now that we've created these markers, we need to store them in a
|
||||||
|
// data structure because they'll need to be deleted and re-created
|
||||||
|
// when their exclusivity changes.
|
||||||
|
this.markersForInsertions.set(insertion, marker)
|
||||||
|
|
||||||
|
if (references) {
|
||||||
|
// The insertion at tab stop `index` (internal numbering) is related
|
||||||
|
// to, and affected by, all the tab stops mentioned in `references`
|
||||||
|
// (internal numbering). We need to make sure we're included in these
|
||||||
|
// other tab stops' exclusivity changes.
|
||||||
|
for (let ref of references) {
|
||||||
|
let relatedInsertions = this.relatedInsertionsByIndex.get(ref) || []
|
||||||
|
relatedInsertions.push(insertion)
|
||||||
|
this.relatedInsertionsByIndex.set(ref, relatedInsertions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.insertionsByIndex[index] = insertions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// When two insertion markers are directly adjacent to one another, and the
|
||||||
|
// cursor is placed right at the border between them, the marker that should
|
||||||
|
// "claim" the newly typed content will vary based on context.
|
||||||
|
//
|
||||||
|
// All else being equal, that content should get added to the marker (if any)
|
||||||
|
// whose tab stop is active, or else the marker whose tab stop's placeholder
|
||||||
|
// references an active tab stop. To use the terminology of Atom's
|
||||||
|
// `DisplayMarker`, all markers related to the active tab stop should be
|
||||||
|
// "inclusive," and all others should be "exclusive."
|
||||||
|
//
|
||||||
|
// Exclusivity cannot be changed after a marker is created. So we need to
|
||||||
|
// revisit the markers whenever the active tab stop changes, figure out which
|
||||||
|
// ones need to be touched, and replace them with markers that have the
|
||||||
|
// settings we need.
|
||||||
|
//
|
||||||
|
adjustTabStopMarkers (oldIndex, newIndex) {
|
||||||
|
// All the insertions belonging to the newly active tab stop (and all
|
||||||
|
// insertions whose placeholders reference the newly active tab stop)
|
||||||
|
// should become inclusive.
|
||||||
|
const insertionsToMakeInclusive = [
|
||||||
|
...this.insertionsByIndex[newIndex],
|
||||||
|
...(this.relatedInsertionsByIndex.get(newIndex) || [])
|
||||||
|
]
|
||||||
|
|
||||||
|
// All insertions that are _not_ related to the newly active tab stop
|
||||||
|
// should become exclusive if they aren't already.
|
||||||
|
let insertionsToMakeExclusive
|
||||||
|
if (oldIndex === null) {
|
||||||
|
// This is the first index to be made active. Since all insertion markers
|
||||||
|
// were initially created to be inclusive, we need to adjust _all_
|
||||||
|
// insertion markers that are not related to the new tab stop.
|
||||||
|
let allInsertions = this.insertionsByIndex.reduce((set, ins) => {
|
||||||
|
set.push(...ins)
|
||||||
|
return set
|
||||||
|
}, [])
|
||||||
|
insertionsToMakeExclusive = allInsertions.filter(ins => {
|
||||||
|
return !insertionsToMakeInclusive.includes(ins)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// We are moving from one tab stop to another, so we only need to touch
|
||||||
|
// the markers related to the tab stop we're departing.
|
||||||
|
insertionsToMakeExclusive = [
|
||||||
|
...this.insertionsByIndex[oldIndex],
|
||||||
|
...(this.relatedInsertionsByIndex.get(oldIndex) || [])
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let insertion of insertionsToMakeExclusive) {
|
||||||
|
this.replaceMarkerForInsertion(insertion, {exclusive: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let insertion of insertionsToMakeInclusive) {
|
||||||
|
this.replaceMarkerForInsertion(insertion, {exclusive: false})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceMarkerForInsertion (insertion, settings) {
|
||||||
|
const marker = this.markersForInsertions.get(insertion)
|
||||||
|
|
||||||
|
// If the marker is invalid or destroyed, return it as-is. Other methods
|
||||||
|
// need to know if a marker has been invalidated or destroyed, and we have
|
||||||
|
// no need to change the settings on such markers anyway.
|
||||||
|
if (!marker.isValid() || marker.isDestroyed()) {
|
||||||
|
return marker
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, create a new marker with an identical range and the specified
|
||||||
|
// settings.
|
||||||
|
const range = marker.getBufferRange()
|
||||||
|
const replacement = this.getMarkerLayer(this.editor).markBufferRange(range, settings)
|
||||||
|
|
||||||
|
marker.destroy()
|
||||||
|
this.markersForInsertions.set(insertion, replacement)
|
||||||
|
return replacement
|
||||||
|
}
|
||||||
|
|
||||||
|
goToNextTabStop () {
|
||||||
|
const nextIndex = this.tabStopIndex + 1
|
||||||
|
if (nextIndex < this.insertionsByIndex.length) {
|
||||||
|
if (this.setTabStopIndex(nextIndex)) {
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return this.goToNextTabStop()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// The user has tabbed past the last tab stop. If the last tab stop is a
|
||||||
|
// $0, we shouldn't move the cursor any further.
|
||||||
|
if (this.snippet.tabStopList.hasEndStop) {
|
||||||
|
this.destroy()
|
||||||
|
return false
|
||||||
|
} else {
|
||||||
|
const succeeded = this.goToEndOfLastTabStop()
|
||||||
|
this.destroy()
|
||||||
|
return succeeded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
goToPreviousTabStop () {
|
||||||
|
if (this.tabStopIndex > 0) { this.setTabStopIndex(this.tabStopIndex - 1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
setTabStopIndex (newIndex) {
|
||||||
|
const oldIndex = this.tabStopIndex
|
||||||
|
this.tabStopIndex = newIndex
|
||||||
|
// Set a flag before moving any selections so that our change handlers know
|
||||||
|
// that the movements were initiated by us.
|
||||||
|
this.settingTabStop = true
|
||||||
|
// Keep track of whether we placed any selections or cursors.
|
||||||
|
let markerSelected = false
|
||||||
|
|
||||||
|
const insertions = this.insertionsByIndex[this.tabStopIndex]
|
||||||
|
if (insertions.length === 0) { return false }
|
||||||
|
|
||||||
|
const ranges = []
|
||||||
|
this.hasTransforms = false
|
||||||
|
|
||||||
|
// Go through the active tab stop's markers to figure out where to place
|
||||||
|
// cursors and/or selections.
|
||||||
|
for (const insertion of insertions) {
|
||||||
|
const marker = this.markersForInsertions.get(insertion)
|
||||||
|
if (marker.isDestroyed()) { continue }
|
||||||
|
if (!marker.isValid()) { continue }
|
||||||
|
if (insertion.isTransformation()) {
|
||||||
|
// Set a flag for later, but skip transformation insertions because
|
||||||
|
// they don't get their own cursors.
|
||||||
|
this.hasTransforms = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ranges.push(marker.getBufferRange())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ranges.length > 0) {
|
||||||
|
// We have new selections to apply. Reuse existing selections if
|
||||||
|
// possible, destroying the unused ones if we already have too many.
|
||||||
|
for (const selection of this.selections.slice(ranges.length)) { selection.destroy() }
|
||||||
|
this.selections = this.selections.slice(0, ranges.length)
|
||||||
|
for (let i = 0; i < ranges.length; i++) {
|
||||||
|
const range = ranges[i]
|
||||||
|
if (this.selections[i]) {
|
||||||
|
this.selections[i].setBufferRange(range)
|
||||||
|
} else {
|
||||||
|
const newSelection = this.editor.addSelectionForBufferRange(range)
|
||||||
|
this.subscriptions.add(newSelection.cursor.onDidChangePosition(event => this.cursorMoved(event)))
|
||||||
|
this.subscriptions.add(newSelection.cursor.onDidDestroy(() => this.cursorDestroyed()))
|
||||||
|
this.selections.push(newSelection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// We placed at least one selection, so this tab stop was successfully
|
||||||
|
// set.
|
||||||
|
markerSelected = true
|
||||||
|
}
|
||||||
|
|
||||||
|
this.settingTabStop = false
|
||||||
|
// If this snippet has at least one transform, we need to observe changes
|
||||||
|
// made to the editor so that we can update the transformed tab stops.
|
||||||
|
if (this.hasTransforms) {
|
||||||
|
this.snippets.observeEditor(this.editor)
|
||||||
|
} else {
|
||||||
|
this.snippets.stopObservingEditor(this.editor)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.adjustTabStopMarkers(oldIndex, newIndex)
|
||||||
|
|
||||||
|
return markerSelected
|
||||||
|
}
|
||||||
|
|
||||||
|
goToEndOfLastTabStop () {
|
||||||
|
const size = this.insertionsByIndex.length
|
||||||
|
if (size === 0) { return }
|
||||||
|
const insertions = this.insertionsByIndex[size - 1]
|
||||||
|
if (insertions.length === 0) { return }
|
||||||
|
const lastMarker = this.markersForInsertions.get(insertions[insertions.length - 1])
|
||||||
|
|
||||||
|
if (lastMarker.isDestroyed()) {
|
||||||
|
return false
|
||||||
|
} else {
|
||||||
|
this.editor.setCursorBufferPosition(lastMarker.getEndBufferPosition())
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy () {
|
||||||
|
this.subscriptions.dispose()
|
||||||
|
this.getMarkerLayer(this.editor).clear()
|
||||||
|
this.insertionsByIndex = []
|
||||||
|
this.relatedInsertionsByIndex.clear()
|
||||||
|
this.markersForInsertions.clear()
|
||||||
|
this.resolutionsForVariables.clear()
|
||||||
|
this.markersForVariables.clear()
|
||||||
|
|
||||||
|
this.snippets.stopObservingEditor(this.editor)
|
||||||
|
this.snippets.clearExpansions(this.editor)
|
||||||
|
}
|
||||||
|
|
||||||
|
getMarkerLayer () {
|
||||||
|
return this.snippets.findOrCreateMarkerLayer(this.editor)
|
||||||
|
}
|
||||||
|
|
||||||
|
restore (editor) {
|
||||||
|
this.editor = editor
|
||||||
|
this.snippets.addExpansion(this.editor, this)
|
||||||
|
}
|
||||||
|
}
|
27
packages/snippets/lib/snippet-history-provider.js
Normal file
27
packages/snippets/lib/snippet-history-provider.js
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
function wrap (manager, callbacks) {
|
||||||
|
let klass = new SnippetHistoryProvider(manager)
|
||||||
|
return new Proxy(manager, {
|
||||||
|
get (target, name) {
|
||||||
|
if (name in callbacks) {
|
||||||
|
callbacks[name]()
|
||||||
|
}
|
||||||
|
return name in klass ? klass[name] : target[name]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
class SnippetHistoryProvider {
|
||||||
|
constructor (manager) {
|
||||||
|
this.manager = manager
|
||||||
|
}
|
||||||
|
|
||||||
|
undo (...args) {
|
||||||
|
return this.manager.undo(...args)
|
||||||
|
}
|
||||||
|
|
||||||
|
redo (...args) {
|
||||||
|
return this.manager.redo(...args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = wrap
|
109
packages/snippets/lib/snippet.js
Normal file
109
packages/snippets/lib/snippet.js
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
const {Point, Range} = require('atom')
|
||||||
|
const TabStopList = require('./tab-stop-list')
|
||||||
|
const Variable = require('./variable')
|
||||||
|
|
||||||
|
function tabStopsReferencedWithinTabStopContent (segment) {
|
||||||
|
const results = []
|
||||||
|
for (const item of segment) {
|
||||||
|
if (item.index) {
|
||||||
|
results.push(item.index, ...tabStopsReferencedWithinTabStopContent(item.content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Set(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = class Snippet {
|
||||||
|
constructor (attrs) {
|
||||||
|
let {
|
||||||
|
id,
|
||||||
|
bodyText,
|
||||||
|
bodyTree,
|
||||||
|
command,
|
||||||
|
description,
|
||||||
|
descriptionMoreURL,
|
||||||
|
leftLabel,
|
||||||
|
leftLabelHTML,
|
||||||
|
name,
|
||||||
|
prefix,
|
||||||
|
packageName,
|
||||||
|
rightLabelHTML,
|
||||||
|
selector
|
||||||
|
} = attrs
|
||||||
|
|
||||||
|
this.id = id
|
||||||
|
this.name = name
|
||||||
|
this.prefix = prefix
|
||||||
|
this.command = command
|
||||||
|
this.packageName = packageName
|
||||||
|
this.bodyText = bodyText
|
||||||
|
this.description = description
|
||||||
|
this.descriptionMoreURL = descriptionMoreURL
|
||||||
|
this.rightLabelHTML = rightLabelHTML
|
||||||
|
this.leftLabel = leftLabel
|
||||||
|
this.leftLabelHTML = leftLabelHTML
|
||||||
|
this.selector = selector
|
||||||
|
|
||||||
|
this.variables = []
|
||||||
|
this.tabStopList = new TabStopList(this)
|
||||||
|
this.body = this.extractTokens(bodyTree)
|
||||||
|
|
||||||
|
if (packageName && command) {
|
||||||
|
this.commandName = `${packageName}:${command}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extractTokens (bodyTree) {
|
||||||
|
const bodyText = []
|
||||||
|
let row = 0, column = 0
|
||||||
|
|
||||||
|
let extract = bodyTree => {
|
||||||
|
for (let segment of bodyTree) {
|
||||||
|
if (segment.index != null) {
|
||||||
|
// Tabstop.
|
||||||
|
let {index, content, substitution} = segment
|
||||||
|
// Ensure tabstop `$0` is always last.
|
||||||
|
if (index === 0) { index = Infinity }
|
||||||
|
|
||||||
|
const start = [row, column]
|
||||||
|
extract(content)
|
||||||
|
|
||||||
|
const referencedTabStops = tabStopsReferencedWithinTabStopContent(content)
|
||||||
|
|
||||||
|
const range = new Range(start, [row, column])
|
||||||
|
|
||||||
|
const tabStop = this.tabStopList.findOrCreate({
|
||||||
|
index, snippet: this
|
||||||
|
})
|
||||||
|
|
||||||
|
tabStop.addInsertion({
|
||||||
|
range,
|
||||||
|
substitution,
|
||||||
|
references: [...referencedTabStops]
|
||||||
|
})
|
||||||
|
} else if (segment.variable != null) {
|
||||||
|
// Variable.
|
||||||
|
let point = new Point(row, column)
|
||||||
|
this.variables.push(
|
||||||
|
new Variable({...segment, point, snippet: this})
|
||||||
|
)
|
||||||
|
} else if (typeof segment === 'string') {
|
||||||
|
bodyText.push(segment)
|
||||||
|
let segmentLines = segment.split('\n')
|
||||||
|
column += segmentLines.shift().length
|
||||||
|
let nextLine
|
||||||
|
while ((nextLine = segmentLines.shift()) != null) {
|
||||||
|
row += 1
|
||||||
|
column = nextLine.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extract(bodyTree)
|
||||||
|
this.lineCount = row + 1
|
||||||
|
this.insertions = this.tabStopList.getInsertions()
|
||||||
|
|
||||||
|
return bodyText.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
84
packages/snippets/lib/snippets-available.js
Normal file
84
packages/snippets/lib/snippets-available.js
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
/** @babel */
|
||||||
|
|
||||||
|
import _ from 'underscore-plus'
|
||||||
|
import SelectListView from 'atom-select-list'
|
||||||
|
|
||||||
|
export default class SnippetsAvailable {
|
||||||
|
constructor (snippets) {
|
||||||
|
this.panel = null
|
||||||
|
this.snippets = snippets
|
||||||
|
this.selectListView = new SelectListView({
|
||||||
|
items: [],
|
||||||
|
filterKeyForItem: (snippet) => snippet.searchText,
|
||||||
|
elementForItem: (snippet) => {
|
||||||
|
const li = document.createElement('li')
|
||||||
|
li.classList.add('two-lines')
|
||||||
|
|
||||||
|
const primaryLine = document.createElement('div')
|
||||||
|
primaryLine.classList.add('primary-line')
|
||||||
|
primaryLine.textContent = snippet.prefix
|
||||||
|
li.appendChild(primaryLine)
|
||||||
|
|
||||||
|
const secondaryLine = document.createElement('div')
|
||||||
|
secondaryLine.classList.add('secondary-line')
|
||||||
|
secondaryLine.textContent = snippet.name
|
||||||
|
li.appendChild(secondaryLine)
|
||||||
|
|
||||||
|
return li
|
||||||
|
},
|
||||||
|
didConfirmSelection: (snippet) => {
|
||||||
|
for (const cursor of this.editor.getCursors()) {
|
||||||
|
this.snippets.insert(snippet.bodyText, this.editor, cursor)
|
||||||
|
}
|
||||||
|
this.cancel()
|
||||||
|
},
|
||||||
|
didConfirmEmptySelection: () => {
|
||||||
|
this.cancel()
|
||||||
|
},
|
||||||
|
didCancelSelection: () => {
|
||||||
|
this.cancel()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.selectListView.element.classList.add('available-snippets')
|
||||||
|
this.element = this.selectListView.element
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggle (editor) {
|
||||||
|
this.editor = editor
|
||||||
|
if (this.panel != null) {
|
||||||
|
this.cancel()
|
||||||
|
} else {
|
||||||
|
this.selectListView.reset()
|
||||||
|
await this.populate()
|
||||||
|
this.attach()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel () {
|
||||||
|
this.editor = null
|
||||||
|
|
||||||
|
if (this.panel != null) {
|
||||||
|
this.panel.destroy()
|
||||||
|
this.panel = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.previouslyFocusedElement) {
|
||||||
|
this.previouslyFocusedElement.focus()
|
||||||
|
this.previouslyFocusedElement = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
populate () {
|
||||||
|
const snippets = Object.values(this.snippets.getSnippets(this.editor))
|
||||||
|
for (let snippet of snippets) {
|
||||||
|
snippet.searchText = _.compact([snippet.prefix, snippet.name]).join(' ')
|
||||||
|
}
|
||||||
|
return this.selectListView.update({items: snippets})
|
||||||
|
}
|
||||||
|
|
||||||
|
attach () {
|
||||||
|
this.previouslyFocusedElement = document.activeElement
|
||||||
|
this.panel = atom.workspace.addModalPanel({item: this})
|
||||||
|
this.selectListView.focus()
|
||||||
|
}
|
||||||
|
}
|
57
packages/snippets/lib/snippets.cson
Normal file
57
packages/snippets/lib/snippets.cson
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
'.source.json':
|
||||||
|
'Atom Snippet':
|
||||||
|
prefix: 'snip'
|
||||||
|
body: """
|
||||||
|
{
|
||||||
|
"${1:.source.js}": {
|
||||||
|
"${2:Snippet Name}": {
|
||||||
|
"prefix": "${3:Snippet Trigger}",
|
||||||
|
"body": "${4:Hello World!}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}$5
|
||||||
|
"""
|
||||||
|
|
||||||
|
'Atom Snippet With No Selector':
|
||||||
|
prefix: 'snipns'
|
||||||
|
body: """
|
||||||
|
"${1:Snippet Name}": {
|
||||||
|
"prefix": "${2:Snippet Trigger}",
|
||||||
|
"body": "${3:Hello World!}"
|
||||||
|
}$4
|
||||||
|
"""
|
||||||
|
|
||||||
|
'Atom Keymap':
|
||||||
|
prefix: 'key'
|
||||||
|
body: """
|
||||||
|
{
|
||||||
|
"${1:body}": {
|
||||||
|
"${2:cmd}-${3:i}": "${4:namespace}:${5:event}"
|
||||||
|
}
|
||||||
|
}$6
|
||||||
|
"""
|
||||||
|
|
||||||
|
'.source.coffee':
|
||||||
|
'Atom Snippet':
|
||||||
|
prefix: 'snip'
|
||||||
|
body: """
|
||||||
|
'${1:.source.js}':
|
||||||
|
'${2:Snippet Name}':
|
||||||
|
'prefix': '${3:Snippet Trigger}'
|
||||||
|
'body': '${4:Hello World!}'$5
|
||||||
|
"""
|
||||||
|
|
||||||
|
'Atom Snippet With No Selector':
|
||||||
|
prefix: 'snipns'
|
||||||
|
body: """
|
||||||
|
'${1:Snippet Name}':
|
||||||
|
'prefix': '${2:Snippet Trigger}'
|
||||||
|
'body': '${3:Hello World!}'$4
|
||||||
|
"""
|
||||||
|
|
||||||
|
'Atom Keymap':
|
||||||
|
prefix: 'key'
|
||||||
|
body: """
|
||||||
|
'${1:body}':
|
||||||
|
'${2:cmd}-${3:i}': '${4:namespace}:${5:event}'$6
|
||||||
|
"""
|
936
packages/snippets/lib/snippets.js
Normal file
936
packages/snippets/lib/snippets.js
Normal file
@ -0,0 +1,936 @@
|
|||||||
|
const path = require('path')
|
||||||
|
const {Emitter, Disposable, CompositeDisposable, File} = require('atom')
|
||||||
|
const _ = require('underscore-plus')
|
||||||
|
const async = require('async')
|
||||||
|
const CSON = require('season')
|
||||||
|
const fs = require('fs')
|
||||||
|
const ScopedPropertyStore = require('scoped-property-store')
|
||||||
|
|
||||||
|
const Snippet = require('./snippet')
|
||||||
|
const SnippetExpansion = require('./snippet-expansion')
|
||||||
|
const EditorStore = require('./editor-store')
|
||||||
|
const {getPackageRoot} = require('./helpers')
|
||||||
|
|
||||||
|
// TODO: Not sure about validity of numbers in here, but might as well be
|
||||||
|
// permissive.
|
||||||
|
const COMMAND_NAME_PATTERN = /^[a-z\d][a-z\d\-]*[a-z\d]$/
|
||||||
|
function isValidCommandName (commandName) {
|
||||||
|
return COMMAND_NAME_PATTERN.test(commandName)
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCommandNameConflictNotification (name, commandName, packageName, snippetsPath) {
|
||||||
|
let remedy
|
||||||
|
if (packageName === 'builtin') {
|
||||||
|
// If somehow this happens with a builtin snippet, something crazy is
|
||||||
|
// happening. But we shouldn't show a notification because there's no
|
||||||
|
// action for the user to take. Just fail silently.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (packageName === 'snippets') {
|
||||||
|
let extension = snippetsPath.substring(snippetsPath.length - 4)
|
||||||
|
remedy = `Edit your \`snippets.${extension}\` file to resolve this conflict.`
|
||||||
|
} else {
|
||||||
|
remedy = `Contact the maintainer of \`${packageName}\` so they can resolve this conflict.`
|
||||||
|
}
|
||||||
|
const message = `Cannot register command \`${commandName}\` for snippet “${name}” because that command name already exists.\n\n${remedy}`
|
||||||
|
atom.notifications.addError(
|
||||||
|
`Snippets conflict`,
|
||||||
|
{
|
||||||
|
description: message,
|
||||||
|
dismissable: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function showInvalidCommandNameNotification (name, commandName) {
|
||||||
|
const message = `Cannot register \`${commandName}\` for snippet “${name}” because the command name isn’t valid. Command names must be all lowercase and use hyphens between words instead of spaces.`
|
||||||
|
atom.notifications.addError(
|
||||||
|
`Snippets error`,
|
||||||
|
{
|
||||||
|
description: message,
|
||||||
|
dismissable: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// When we first run, checking `atom.commands.registeredCommands` is a good way
|
||||||
|
// of checking whether a command of a certain name already exists. But if we
|
||||||
|
// register a command and then unregister it (e.g., upon later disabling of a
|
||||||
|
// package's snippets), the relevant key won't get deleted from
|
||||||
|
// `registeredCommands`. So if the user re-enables the snippets, we'll
|
||||||
|
// incorrectly think that the command already exists.
|
||||||
|
//
|
||||||
|
// Hence, after the first check, we have to keep track ourselves. At least this
|
||||||
|
// gives us a place to keep track of individual command disposables.
|
||||||
|
//
|
||||||
|
const CommandMonitor = {
|
||||||
|
map: new Map,
|
||||||
|
disposables: new Map,
|
||||||
|
compositeDisposable: new CompositeDisposable,
|
||||||
|
exists (commandName) {
|
||||||
|
let {map} = this
|
||||||
|
if (!map.has(commandName)) {
|
||||||
|
// If it's missing altogether from the registry, we haven't asked yet.
|
||||||
|
let value = atom.commands.registeredCommands[commandName]
|
||||||
|
map.set(commandName, value)
|
||||||
|
return value
|
||||||
|
} else {
|
||||||
|
return map.get(commandName)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
add (commandName, disposable) {
|
||||||
|
this.map.set(commandName, true)
|
||||||
|
this.disposables.set(commandName, disposable)
|
||||||
|
this.compositeDisposable.add(disposable)
|
||||||
|
},
|
||||||
|
|
||||||
|
remove (commandName) {
|
||||||
|
this.map.set(commandName, false)
|
||||||
|
let disposable = this.disposables.get(commandName)
|
||||||
|
if (disposable) { disposable.dispose() }
|
||||||
|
},
|
||||||
|
|
||||||
|
reset () {
|
||||||
|
this.map.clear()
|
||||||
|
this.disposables.clear()
|
||||||
|
this.compositeDisposable.dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// When we load snippets from packages, we're given a bunch of package paths
|
||||||
|
// instead of package names. This lets us match the former to the latter.
|
||||||
|
const PackageNameResolver = {
|
||||||
|
pathsToNames: new Map,
|
||||||
|
setup () {
|
||||||
|
this.pathsToNames.clear()
|
||||||
|
let meta = atom.packages.getLoadedPackages() || []
|
||||||
|
for (let {name, path} of meta) {
|
||||||
|
this.pathsToNames.set(path, name)
|
||||||
|
}
|
||||||
|
if (!this._observing) {
|
||||||
|
atom.packages.onDidLoadPackage(() => this.setup())
|
||||||
|
atom.packages.onDidUnloadPackage(() => this.setup())
|
||||||
|
}
|
||||||
|
this._observing = true
|
||||||
|
},
|
||||||
|
find (filePath) {
|
||||||
|
for (let [packagePath, name] of this.pathsToNames.entries()) {
|
||||||
|
if (filePath.startsWith(`${packagePath}${path.sep}`)) return name
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
activate () {
|
||||||
|
this.loaded = false
|
||||||
|
this.userSnippetsPath = null
|
||||||
|
this.snippetIdCounter = 0
|
||||||
|
this.snippetsByPackage = new Map
|
||||||
|
this.parsedSnippetsById = new Map
|
||||||
|
this.editorMarkerLayers = new WeakMap
|
||||||
|
|
||||||
|
this.scopedPropertyStore = new ScopedPropertyStore
|
||||||
|
// The above ScopedPropertyStore will store the main registry of snippets.
|
||||||
|
// But we need a separate ScopedPropertyStore for the snippets that come
|
||||||
|
// from disabled packages. They're isolated so that they're not considered
|
||||||
|
// as candidates when the user expands a prefix, but we still need the data
|
||||||
|
// around so that the snippets provided by those packages can be shown in
|
||||||
|
// the settings view.
|
||||||
|
this.disabledSnippetsScopedPropertyStore = new ScopedPropertyStore
|
||||||
|
|
||||||
|
this.subscriptions = new CompositeDisposable
|
||||||
|
this.subscriptions.add(atom.workspace.addOpener(uri => {
|
||||||
|
if (uri === 'atom://.pulsar/snippets') {
|
||||||
|
return atom.workspace.openTextFile(this.getUserSnippetsPath())
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
PackageNameResolver.setup()
|
||||||
|
|
||||||
|
this.loadAll()
|
||||||
|
this.watchUserSnippets(watchDisposable => {
|
||||||
|
this.subscriptions.add(watchDisposable)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.subscriptions.add(
|
||||||
|
atom.config.onDidChange(
|
||||||
|
'core.packagesWithSnippetsDisabled',
|
||||||
|
({newValue, oldValue}) => {
|
||||||
|
this.handleDisabledPackagesDidChange(newValue, oldValue)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const snippets = this
|
||||||
|
|
||||||
|
this.subscriptions.add(atom.commands.add('atom-text-editor', {
|
||||||
|
'snippets:expand' (event) {
|
||||||
|
const editor = this.getModel()
|
||||||
|
if (snippets.snippetToExpandUnderCursor(editor)) {
|
||||||
|
snippets.clearExpansions(editor)
|
||||||
|
snippets.expandSnippetsUnderCursors(editor)
|
||||||
|
} else {
|
||||||
|
event.abortKeyBinding()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
'snippets:next-tab-stop' (event) {
|
||||||
|
const editor = this.getModel()
|
||||||
|
if (!snippets.goToNextTabStop(editor)) { event.abortKeyBinding() }
|
||||||
|
},
|
||||||
|
|
||||||
|
'snippets:previous-tab-stop' (event) {
|
||||||
|
const editor = this.getModel()
|
||||||
|
if (!snippets.goToPreviousTabStop(editor)) { event.abortKeyBinding() }
|
||||||
|
},
|
||||||
|
|
||||||
|
'snippets:available' (event) {
|
||||||
|
const editor = this.getModel()
|
||||||
|
const SnippetsAvailable = require('./snippets-available')
|
||||||
|
if (snippets.availableSnippetsView == null) {
|
||||||
|
snippets.availableSnippetsView = new SnippetsAvailable(snippets)
|
||||||
|
}
|
||||||
|
snippets.availableSnippetsView.toggle(editor)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
deactivate () {
|
||||||
|
if (this.emitter != null) {
|
||||||
|
this.emitter.dispose()
|
||||||
|
}
|
||||||
|
this.emitter = null
|
||||||
|
this.editorSnippetExpansions = null
|
||||||
|
atom.config.transact(() => this.subscriptions.dispose())
|
||||||
|
CommandMonitor.reset()
|
||||||
|
},
|
||||||
|
|
||||||
|
getUserSnippetsPath () {
|
||||||
|
if (this.userSnippetsPath != null) { return this.userSnippetsPath }
|
||||||
|
|
||||||
|
this.userSnippetsPath = CSON.resolve(path.join(atom.getConfigDirPath(), 'snippets'))
|
||||||
|
if (this.userSnippetsPath == null) { this.userSnippetsPath = path.join(atom.getConfigDirPath(), 'snippets.cson') }
|
||||||
|
return this.userSnippetsPath
|
||||||
|
},
|
||||||
|
|
||||||
|
loadAll () {
|
||||||
|
this.loadBundledSnippets(bundledSnippets => {
|
||||||
|
this.loadPackageSnippets(packageSnippets => {
|
||||||
|
this.loadUserSnippets(userSnippets => {
|
||||||
|
atom.config.transact(() => {
|
||||||
|
for (const [filepath, snippetsBySelector] of Object.entries(bundledSnippets)) {
|
||||||
|
this.add(filepath, snippetsBySelector, 'builtin')
|
||||||
|
}
|
||||||
|
for (const [filepath, snippetsBySelector] of Object.entries(packageSnippets)) {
|
||||||
|
let packageName = PackageNameResolver.find(filepath) || 'snippets'
|
||||||
|
this.add(filepath, snippetsBySelector, packageName)
|
||||||
|
}
|
||||||
|
for (const [filepath, snippetsBySelector] of Object.entries(userSnippets)) {
|
||||||
|
this.add(filepath, snippetsBySelector, 'snippets')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.doneLoading()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
loadBundledSnippets (callback) {
|
||||||
|
const bundledSnippetsPath = CSON.resolve(path.join(getPackageRoot(), 'lib', 'snippets'))
|
||||||
|
this.loadSnippetsFile(bundledSnippetsPath, snippets => {
|
||||||
|
const snippetsByPath = {}
|
||||||
|
snippetsByPath[bundledSnippetsPath] = snippets
|
||||||
|
callback(snippetsByPath)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
loadUserSnippets (callback) {
|
||||||
|
const userSnippetsPath = this.getUserSnippetsPath()
|
||||||
|
fs.stat(userSnippetsPath, (error, stat) => {
|
||||||
|
if (stat != null && stat.isFile()) {
|
||||||
|
this.loadSnippetsFile(userSnippetsPath, snippets => {
|
||||||
|
const result = {}
|
||||||
|
result[userSnippetsPath] = snippets
|
||||||
|
callback(result)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
callback({})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
watchUserSnippets (callback) {
|
||||||
|
const userSnippetsPath = this.getUserSnippetsPath()
|
||||||
|
fs.stat(userSnippetsPath, (error, stat) => {
|
||||||
|
if (stat != null && stat.isFile()) {
|
||||||
|
const userSnippetsFileDisposable = new CompositeDisposable()
|
||||||
|
const userSnippetsFile = new File(userSnippetsPath)
|
||||||
|
try {
|
||||||
|
userSnippetsFileDisposable.add(userSnippetsFile.onDidChange(() => this.handleUserSnippetsDidChange()))
|
||||||
|
userSnippetsFileDisposable.add(userSnippetsFile.onDidDelete(() => this.handleUserSnippetsDidChange()))
|
||||||
|
userSnippetsFileDisposable.add(userSnippetsFile.onDidRename(() => this.handleUserSnippetsDidChange()))
|
||||||
|
} catch (e) {
|
||||||
|
const message = `\
|
||||||
|
Unable to watch path: \`snippets.cson\`. Make sure you have permissions
|
||||||
|
to the \`~/.pulsar\` directory and \`${userSnippetsPath}\`.
|
||||||
|
|
||||||
|
On linux there are currently problems with watch sizes. See
|
||||||
|
[this document][watches] for more info.
|
||||||
|
[watches]:https://github.com/atom/atom/blob/master/docs/build-instructions/linux.md#typeerror-unable-to-watch-path\
|
||||||
|
`
|
||||||
|
atom.notifications.addError(message, {dismissable: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(userSnippetsFileDisposable)
|
||||||
|
} else {
|
||||||
|
callback(new Disposable())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// Called when a user's snippets file is changed, deleted, or moved so that we
|
||||||
|
// can immediately re-process the snippets it contains.
|
||||||
|
handleUserSnippetsDidChange () {
|
||||||
|
// TODO: There appear to be scenarios where this method gets invoked more
|
||||||
|
// than once with each change to the user's `snippets.cson`. To prevent
|
||||||
|
// more than one concurrent rescan of the snippets file, we block any
|
||||||
|
// additional calls to this method while the first call is still operating.
|
||||||
|
const userSnippetsPath = this.getUserSnippetsPath()
|
||||||
|
|
||||||
|
if (this.isHandlingUserSnippetsChange) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isHandlingUserSnippetsChange = true
|
||||||
|
atom.config.transact(() => {
|
||||||
|
this.clearSnippetsForPath(userSnippetsPath)
|
||||||
|
this.loadSnippetsFile(userSnippetsPath, result => {
|
||||||
|
this.add(userSnippetsPath, result, 'snippets')
|
||||||
|
this.isHandlingUserSnippetsChange = false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// Called when the "Enable" checkbox is checked/unchecked in the Snippets
|
||||||
|
// section of a package's settings view.
|
||||||
|
handleDisabledPackagesDidChange (newDisabledPackages = [], oldDisabledPackages = []) {
|
||||||
|
const packagesToAdd = []
|
||||||
|
const packagesToRemove = []
|
||||||
|
for (const p of oldDisabledPackages) {
|
||||||
|
if (!newDisabledPackages.includes(p)) { packagesToAdd.push(p) }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p of newDisabledPackages) {
|
||||||
|
if (!oldDisabledPackages.includes(p)) { packagesToRemove.push(p) }
|
||||||
|
}
|
||||||
|
|
||||||
|
atom.config.transact(() => {
|
||||||
|
for (const p of packagesToRemove) { this.removeSnippetsForPackage(p) }
|
||||||
|
for (const p of packagesToAdd) { this.addSnippetsForPackage(p) }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
addSnippetsForPackage (packageName) {
|
||||||
|
const snippetSet = this.snippetsByPackage.get(packageName)
|
||||||
|
for (const filePath in snippetSet) {
|
||||||
|
const snippetsBySelector = snippetSet[filePath]
|
||||||
|
this.add(filePath, snippetsBySelector, packageName)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeSnippetsForPackage (packageName) {
|
||||||
|
const snippetSet = this.snippetsByPackage.get(packageName)
|
||||||
|
// Copy these snippets to the "quarantined" ScopedPropertyStore so that they
|
||||||
|
// remain present in the list of unparsed snippets reported to the settings
|
||||||
|
// view.
|
||||||
|
this.addSnippetsInDisabledPackage(snippetSet)
|
||||||
|
for (const filePath in snippetSet) {
|
||||||
|
this.clearSnippetsForPath(filePath)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadPackageSnippets (callback) {
|
||||||
|
const disabledPackageNames = atom.config.get('core.packagesWithSnippetsDisabled') || []
|
||||||
|
const packages = atom.packages.getLoadedPackages().sort((pack, _) => {
|
||||||
|
return pack.path.includes(`${path.sep}node_modules${path.sep}`) ? -1 : 1
|
||||||
|
})
|
||||||
|
|
||||||
|
const snippetsDirPaths = []
|
||||||
|
for (const pack of packages) {
|
||||||
|
snippetsDirPaths.push(path.join(pack.path, 'snippets'))
|
||||||
|
}
|
||||||
|
|
||||||
|
async.map(snippetsDirPaths, this.loadSnippetsDirectory.bind(this), (error, results) => {
|
||||||
|
const zipped = []
|
||||||
|
for (const key in results) {
|
||||||
|
zipped.push({result: results[key], pack: packages[key]})
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabledPackages = []
|
||||||
|
for (const o of zipped) {
|
||||||
|
// Skip packages that contain no snippets.
|
||||||
|
if (Object.keys(o.result).length === 0) { continue }
|
||||||
|
// Keep track of which snippets come from which packages so we can
|
||||||
|
// unload them selectively later. All packages get put into this map,
|
||||||
|
// even disabled packages, because we need to know which snippets to add
|
||||||
|
// if those packages are enabled again.
|
||||||
|
this.snippetsByPackage.set(o.pack.name, o.result)
|
||||||
|
if (disabledPackageNames.includes(o.pack.name)) {
|
||||||
|
// Since disabled packages' snippets won't get added to the main
|
||||||
|
// ScopedPropertyStore, we'll keep track of them in a separate
|
||||||
|
// ScopedPropertyStore so that they can still be represented in the
|
||||||
|
// settings view.
|
||||||
|
this.addSnippetsInDisabledPackage(o.result)
|
||||||
|
} else {
|
||||||
|
enabledPackages.push(o.result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(_.extend({}, ...enabledPackages))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
doneLoading () {
|
||||||
|
this.loaded = true
|
||||||
|
this.getEmitter().emit('did-load-snippets')
|
||||||
|
},
|
||||||
|
|
||||||
|
onDidLoadSnippets (callback) {
|
||||||
|
this.getEmitter().on('did-load-snippets', callback)
|
||||||
|
},
|
||||||
|
|
||||||
|
getEmitter () {
|
||||||
|
if (this.emitter == null) {
|
||||||
|
this.emitter = new Emitter
|
||||||
|
}
|
||||||
|
return this.emitter
|
||||||
|
},
|
||||||
|
|
||||||
|
loadSnippetsDirectory (snippetsDirPath, callback) {
|
||||||
|
fs.stat(snippetsDirPath, (error, stat) => {
|
||||||
|
if (error || !stat.isDirectory()) return callback(null, {})
|
||||||
|
|
||||||
|
fs.readdir(snippetsDirPath, (error, entries) => {
|
||||||
|
if (error) {
|
||||||
|
console.warn(`Error reading snippets directory ${snippetsDirPath}`, error)
|
||||||
|
return callback(null, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
async.map(
|
||||||
|
entries,
|
||||||
|
(entry, done) => {
|
||||||
|
const filePath = path.join(snippetsDirPath, entry)
|
||||||
|
this.loadSnippetsFile(filePath, snippets => done(null, {filePath, snippets}))
|
||||||
|
},
|
||||||
|
(error, results) => {
|
||||||
|
const snippetsByPath = {}
|
||||||
|
for (const {filePath, snippets} of results) {
|
||||||
|
snippetsByPath[filePath] = snippets
|
||||||
|
}
|
||||||
|
callback(null, snippetsByPath)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
loadSnippetsFile (filePath, callback) {
|
||||||
|
if (!CSON.isObjectPath(filePath)) { return callback({}) }
|
||||||
|
CSON.readFile(filePath, {allowDuplicateKeys: false}, (error, object = {}) => {
|
||||||
|
if (error != null) {
|
||||||
|
console.warn(`Error reading snippets file '${filePath}': ${error.stack != null ? error.stack : error}`)
|
||||||
|
atom.notifications.addError(`Failed to load snippets from '${filePath}'`, {detail: error.message, dismissable: true})
|
||||||
|
}
|
||||||
|
callback(object)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
add (filePath, snippetsBySelector, packageName = null, isDisabled = false) {
|
||||||
|
packageName ??= 'snippets'
|
||||||
|
for (const selector in snippetsBySelector) {
|
||||||
|
const snippetsByName = snippetsBySelector[selector]
|
||||||
|
const unparsedSnippetsByPrefix = {}
|
||||||
|
for (const name in snippetsByName) {
|
||||||
|
const attributes = snippetsByName[name]
|
||||||
|
const {prefix, command, body} = attributes
|
||||||
|
if (!prefix && !command) {
|
||||||
|
// A snippet must define either `prefix` or `command`, or both.
|
||||||
|
// TODO: Worth showing notification?
|
||||||
|
console.error(`Skipping snippet ${name}: no "prefix" or "command" property present`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
attributes.selector = selector
|
||||||
|
attributes.name = name
|
||||||
|
attributes.id = this.snippetIdCounter++
|
||||||
|
attributes.packageName = packageName
|
||||||
|
// Snippets with "prefix"es will get indexed according to that prefix.
|
||||||
|
// Snippets without "prefix"es will be indexed by their ID below _if_
|
||||||
|
// they have a "command" property. Snippets without "prefix" or
|
||||||
|
// "command" have already been filtered out.
|
||||||
|
if (prefix) {
|
||||||
|
if (typeof body === 'string') {
|
||||||
|
unparsedSnippetsByPrefix[prefix] = attributes
|
||||||
|
} else if (body == null) {
|
||||||
|
unparsedSnippetsByPrefix[prefix] = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (command) {
|
||||||
|
if (!isValidCommandName(command)) {
|
||||||
|
showInvalidCommandNameNotification(name, command)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!prefix) {
|
||||||
|
// We need a key for these snippets that will not clash with any
|
||||||
|
// prefix key. Since prefixes aren't allowed to have spaces, we'll
|
||||||
|
// put a space in this key.
|
||||||
|
//
|
||||||
|
// We'll use the snippet ID as part of the key. If a snippet's
|
||||||
|
// `command` property clashes with another command, we'll catch
|
||||||
|
// that later.
|
||||||
|
let unparsedSnippetsKey = `command ${attributes.id}`
|
||||||
|
if (typeof body === 'string') {
|
||||||
|
unparsedSnippetsByPrefix[unparsedSnippetsKey] = attributes
|
||||||
|
} else {
|
||||||
|
unparsedSnippetsByPrefix[unparsedSnippetsKey] = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isDisabled) {
|
||||||
|
this.addCommandForSnippet(attributes, packageName, selector)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.storeUnparsedSnippets(unparsedSnippetsByPrefix, filePath, selector, packageName, isDisabled)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommandForSnippet (attributes, packageName, selector) {
|
||||||
|
packageName = packageName || 'snippets'
|
||||||
|
let {name, command} = attributes
|
||||||
|
let commandName = `${packageName}:${command}`
|
||||||
|
if (CommandMonitor.exists(commandName)) {
|
||||||
|
console.error(`Skipping ${commandName} because it's already been registered!`)
|
||||||
|
showCommandNameConflictNotification(
|
||||||
|
name,
|
||||||
|
commandName,
|
||||||
|
packageName,
|
||||||
|
this.getUserSnippetsPath()
|
||||||
|
)
|
||||||
|
// We won't remove the snippet because it might still be triggerable by
|
||||||
|
// prefix. But we will null out the `command` property to prevent any
|
||||||
|
// possible confusion.
|
||||||
|
attributes.command = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let commandHandler = (event) => {
|
||||||
|
let editor = event.target.closest('atom-text-editor').getModel()
|
||||||
|
|
||||||
|
// We match the multi-cursor behavior that prefix-triggered snippets
|
||||||
|
// exhibit: only the last cursor determines which scoped set of snippets
|
||||||
|
// we pull, but we'll insert this snippet for each cursor, whether it
|
||||||
|
// happens to be valid for that cursor's scope or not. This could
|
||||||
|
// possibly be refined in the future.
|
||||||
|
let snippets = this.getSnippets(editor)
|
||||||
|
|
||||||
|
let targetSnippet = null
|
||||||
|
for (let snippet of Object.values(snippets)) {
|
||||||
|
if (snippet.id === attributes.id) {
|
||||||
|
targetSnippet = snippet
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetSnippet) {
|
||||||
|
// We don't show an error notification here because it isn't
|
||||||
|
// necessarily a mistake. But we put a warning in the console just in
|
||||||
|
// case the user is confused.
|
||||||
|
console.warn(`Snippet “${name}” not invoked because its scope was not matched.`)
|
||||||
|
|
||||||
|
// Because its scope was not matched, we abort the key binding; this
|
||||||
|
// signals to the key binding resolver that it can pick the next
|
||||||
|
// candidate for a key shortcut, if one exists.
|
||||||
|
return event.abortKeyBinding()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.expandSnippet(editor, targetSnippet)
|
||||||
|
}
|
||||||
|
|
||||||
|
let disposable = atom.commands.add(
|
||||||
|
'atom-text-editor',
|
||||||
|
commandName,
|
||||||
|
commandHandler
|
||||||
|
)
|
||||||
|
|
||||||
|
this.subscriptions.add(disposable)
|
||||||
|
CommandMonitor.add(commandName, disposable)
|
||||||
|
},
|
||||||
|
|
||||||
|
addSnippetsInDisabledPackage (bundle) {
|
||||||
|
for (const filePath in bundle) {
|
||||||
|
const snippetsBySelector = bundle[filePath]
|
||||||
|
const packageName = PackageNameResolver.find(filePath)
|
||||||
|
this.add(filePath, snippetsBySelector, packageName, true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getScopeChain (object) {
|
||||||
|
let scopesArray = object
|
||||||
|
if (object && object.getScopesArray) {
|
||||||
|
scopesArray = object.getScopesArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
return scopesArray
|
||||||
|
.map(scope => scope[0] === '.' ? scope : `.${scope}`)
|
||||||
|
.join(' ')
|
||||||
|
},
|
||||||
|
|
||||||
|
storeUnparsedSnippets (value, path, selector, packageName, isDisabled = false) {
|
||||||
|
// The `isDisabled` flag determines which scoped property store we'll use.
|
||||||
|
// Active snippets get put into one and inactive snippets get put into
|
||||||
|
// another. Only the first one gets consulted when we look up a snippet
|
||||||
|
// prefix for expansion, but both stores have their contents exported when
|
||||||
|
// the settings view asks for all available snippets.
|
||||||
|
const unparsedSnippets = {}
|
||||||
|
unparsedSnippets[selector] = {"snippets": value}
|
||||||
|
const store = isDisabled ? this.disabledSnippetsScopedPropertyStore : this.scopedPropertyStore
|
||||||
|
store.addProperties(path, unparsedSnippets, {priority: this.priorityForSource(path)})
|
||||||
|
},
|
||||||
|
|
||||||
|
clearSnippetsForPath (path) {
|
||||||
|
for (const scopeSelector in this.scopedPropertyStore.propertiesForSource(path)) {
|
||||||
|
let object = this.scopedPropertyStore.propertiesForSourceAndSelector(path, scopeSelector)
|
||||||
|
if (object.snippets) { object = object.snippets }
|
||||||
|
for (const prefix in object) {
|
||||||
|
const attributes = object[prefix]
|
||||||
|
if (!attributes) { continue }
|
||||||
|
let {command, packageName} = attributes
|
||||||
|
if (packageName && command) {
|
||||||
|
CommandMonitor.remove(`${packageName}:${command}`)
|
||||||
|
}
|
||||||
|
this.parsedSnippetsById.delete(attributes.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scopedPropertyStore.removePropertiesForSourceAndSelector(path, scopeSelector)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
parsedSnippetsForScopes (scopeDescriptor) {
|
||||||
|
let unparsedLegacySnippetsByPrefix
|
||||||
|
|
||||||
|
const unparsedSnippetsByPrefix = this.scopedPropertyStore.getPropertyValue(
|
||||||
|
this.getScopeChain(scopeDescriptor),
|
||||||
|
"snippets"
|
||||||
|
)
|
||||||
|
|
||||||
|
const legacyScopeDescriptor = atom.config.getLegacyScopeDescriptorForNewScopeDescriptor
|
||||||
|
? atom.config.getLegacyScopeDescriptorForNewScopeDescriptor(scopeDescriptor)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
if (legacyScopeDescriptor) {
|
||||||
|
unparsedLegacySnippetsByPrefix = this.scopedPropertyStore.getPropertyValue(
|
||||||
|
this.getScopeChain(legacyScopeDescriptor),
|
||||||
|
"snippets"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const snippets = {}
|
||||||
|
|
||||||
|
if (unparsedSnippetsByPrefix) {
|
||||||
|
for (const prefix in unparsedSnippetsByPrefix) {
|
||||||
|
const attributes = unparsedSnippetsByPrefix[prefix]
|
||||||
|
if (typeof (attributes != null ? attributes.body : undefined) !== 'string') { continue }
|
||||||
|
snippets[prefix] = this.getParsedSnippet(attributes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unparsedLegacySnippetsByPrefix) {
|
||||||
|
for (const prefix in unparsedLegacySnippetsByPrefix) {
|
||||||
|
const attributes = unparsedLegacySnippetsByPrefix[prefix]
|
||||||
|
if (snippets[prefix]) { continue }
|
||||||
|
if (typeof (attributes != null ? attributes.body : undefined) !== 'string') { continue }
|
||||||
|
snippets[prefix] = this.getParsedSnippet(attributes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return snippets
|
||||||
|
},
|
||||||
|
|
||||||
|
getParsedSnippet (attributes) {
|
||||||
|
let snippet = this.parsedSnippetsById.get(attributes.id)
|
||||||
|
if (snippet == null) {
|
||||||
|
let {id, prefix, command, name, body, bodyTree, description, packageName, descriptionMoreURL, rightLabelHTML, leftLabel, leftLabelHTML, selector} = attributes
|
||||||
|
if (bodyTree == null) { bodyTree = this.getBodyParser().parse(body) }
|
||||||
|
snippet = new Snippet({id, name, prefix, command, bodyTree, description, packageName, descriptionMoreURL, rightLabelHTML, leftLabel, leftLabelHTML, selector, bodyText: body})
|
||||||
|
this.parsedSnippetsById.set(attributes.id, snippet)
|
||||||
|
}
|
||||||
|
return snippet
|
||||||
|
},
|
||||||
|
|
||||||
|
priorityForSource (source) {
|
||||||
|
if (source === this.getUserSnippetsPath()) {
|
||||||
|
return 1000
|
||||||
|
} else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getBodyParser () {
|
||||||
|
if (this.bodyParser == null) {
|
||||||
|
this.bodyParser = require('./snippet-body-parser')
|
||||||
|
}
|
||||||
|
return this.bodyParser
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get an {Object} with these keys:
|
||||||
|
// * `snippetPrefix`: the possible snippet prefix text preceding the cursor
|
||||||
|
// * `wordPrefix`: the word preceding the cursor
|
||||||
|
//
|
||||||
|
// Returns `null` if the values aren't the same for all cursors
|
||||||
|
getPrefixText (snippets, editor) {
|
||||||
|
const wordRegex = this.wordRegexForSnippets(snippets)
|
||||||
|
|
||||||
|
let snippetPrefix = null
|
||||||
|
let wordPrefix = null
|
||||||
|
|
||||||
|
for (const cursor of editor.getCursors()) {
|
||||||
|
const position = cursor.getBufferPosition()
|
||||||
|
|
||||||
|
const prefixStart = cursor.getBeginningOfCurrentWordBufferPosition({wordRegex})
|
||||||
|
const cursorSnippetPrefix = editor.getTextInRange([prefixStart, position])
|
||||||
|
if ((snippetPrefix != null) && (cursorSnippetPrefix !== snippetPrefix)) { return null }
|
||||||
|
snippetPrefix = cursorSnippetPrefix
|
||||||
|
|
||||||
|
const wordStart = cursor.getBeginningOfCurrentWordBufferPosition()
|
||||||
|
const cursorWordPrefix = editor.getTextInRange([wordStart, position])
|
||||||
|
if ((wordPrefix != null) && (cursorWordPrefix !== wordPrefix)) { return null }
|
||||||
|
wordPrefix = cursorWordPrefix
|
||||||
|
}
|
||||||
|
|
||||||
|
return {snippetPrefix, wordPrefix}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get a RegExp of all the characters used in the snippet prefixes
|
||||||
|
wordRegexForSnippets (snippets) {
|
||||||
|
const prefixes = {}
|
||||||
|
|
||||||
|
for (const prefix in snippets) {
|
||||||
|
for (const character of prefix) { prefixes[character] = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefixCharacters = Object.keys(prefixes).join('')
|
||||||
|
return new RegExp(`[${_.escapeRegExp(prefixCharacters)}]+`)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get the best match snippet for the given prefix text. This will return
|
||||||
|
// the longest match where there is no exact match to the prefix text.
|
||||||
|
snippetForPrefix (snippets, prefix, wordPrefix) {
|
||||||
|
let longestPrefixMatch = null
|
||||||
|
|
||||||
|
for (const snippetPrefix in snippets) {
|
||||||
|
// Any snippet without a prefix was keyed on its snippet ID, but with a
|
||||||
|
// space introduced to ensure it would never be a prefix match. But let's
|
||||||
|
// play it safe here anyway.
|
||||||
|
if (snippetPrefix.includes(' ')) { continue }
|
||||||
|
const snippet = snippets[snippetPrefix]
|
||||||
|
if (prefix.endsWith(snippetPrefix) && (wordPrefix.length <= snippetPrefix.length)) {
|
||||||
|
if ((longestPrefixMatch == null) || (snippetPrefix.length > longestPrefixMatch.prefix.length)) {
|
||||||
|
longestPrefixMatch = snippet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return longestPrefixMatch
|
||||||
|
},
|
||||||
|
|
||||||
|
getSnippets (editor) {
|
||||||
|
return this.parsedSnippetsForScopes(editor.getLastCursor().getScopeDescriptor())
|
||||||
|
},
|
||||||
|
|
||||||
|
snippetToExpandUnderCursor (editor) {
|
||||||
|
if (!editor.getLastSelection().isEmpty()) { return false }
|
||||||
|
const snippets = this.getSnippets(editor)
|
||||||
|
if (_.isEmpty(snippets)) { return false }
|
||||||
|
|
||||||
|
const prefixData = this.getPrefixText(snippets, editor)
|
||||||
|
if (prefixData) {
|
||||||
|
return this.snippetForPrefix(snippets, prefixData.snippetPrefix, prefixData.wordPrefix)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Expands a snippet invoked via command.
|
||||||
|
expandSnippet (editor, snippet) {
|
||||||
|
this.getStore(editor).observeHistory({
|
||||||
|
undo: event => { this.onUndoOrRedo(editor, event, true) },
|
||||||
|
redo: event => { this.onUndoOrRedo(editor, event, false) }
|
||||||
|
})
|
||||||
|
|
||||||
|
this.findOrCreateMarkerLayer(editor)
|
||||||
|
|
||||||
|
editor.transact(() => {
|
||||||
|
const cursors = editor.getCursors()
|
||||||
|
for (const cursor of cursors) {
|
||||||
|
this.insert(snippet, editor, cursor, {method: 'command'})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// Expands a snippet defined via tab trigger _if_ such a snippet can be found
|
||||||
|
// for the current prefix and scope.
|
||||||
|
expandSnippetsUnderCursors (editor) {
|
||||||
|
const snippet = this.snippetToExpandUnderCursor(editor)
|
||||||
|
if (!snippet) { return false }
|
||||||
|
|
||||||
|
this.getStore(editor).observeHistory({
|
||||||
|
undo: event => { this.onUndoOrRedo(editor, event, true) },
|
||||||
|
redo: event => { this.onUndoOrRedo(editor, event, false) }
|
||||||
|
})
|
||||||
|
|
||||||
|
this.findOrCreateMarkerLayer(editor)
|
||||||
|
editor.transact(() => {
|
||||||
|
const cursors = editor.getCursors()
|
||||||
|
for (const cursor of cursors) {
|
||||||
|
// Select the prefix text so that it gets consumed when the snippet
|
||||||
|
// expands.
|
||||||
|
const cursorPosition = cursor.getBufferPosition()
|
||||||
|
const startPoint = cursorPosition.translate([0, -snippet.prefix.length], [0, 0])
|
||||||
|
cursor.selection.setBufferRange([startPoint, cursorPosition])
|
||||||
|
this.insert(snippet, editor, cursor, {method: 'prefix'})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
|
||||||
|
goToNextTabStop (editor) {
|
||||||
|
let nextTabStopVisited = false
|
||||||
|
for (const expansion of this.getExpansions(editor)) {
|
||||||
|
if (expansion && expansion.goToNextTabStop()) {
|
||||||
|
nextTabStopVisited = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nextTabStopVisited
|
||||||
|
},
|
||||||
|
|
||||||
|
goToPreviousTabStop (editor) {
|
||||||
|
let previousTabStopVisited = false
|
||||||
|
for (const expansion of this.getExpansions(editor)) {
|
||||||
|
if (expansion && expansion.goToPreviousTabStop()) {
|
||||||
|
previousTabStopVisited = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return previousTabStopVisited
|
||||||
|
},
|
||||||
|
|
||||||
|
getStore (editor) {
|
||||||
|
return EditorStore.findOrCreate(editor)
|
||||||
|
},
|
||||||
|
|
||||||
|
findOrCreateMarkerLayer (editor) {
|
||||||
|
let layer = this.editorMarkerLayers.get(editor)
|
||||||
|
if (layer === undefined) {
|
||||||
|
layer = editor.addMarkerLayer({maintainHistory: true})
|
||||||
|
this.editorMarkerLayers.set(editor, layer)
|
||||||
|
}
|
||||||
|
return layer
|
||||||
|
},
|
||||||
|
|
||||||
|
getExpansions (editor) {
|
||||||
|
return this.getStore(editor).getExpansions()
|
||||||
|
},
|
||||||
|
|
||||||
|
clearExpansions (editor) {
|
||||||
|
const store = this.getStore(editor)
|
||||||
|
store.clearExpansions()
|
||||||
|
// There are no more active instances of this expansion, so we should undo
|
||||||
|
// the spying we set up on this editor.
|
||||||
|
store.stopObserving()
|
||||||
|
store.stopObservingHistory()
|
||||||
|
},
|
||||||
|
|
||||||
|
addExpansion (editor, snippetExpansion) {
|
||||||
|
this.getStore(editor).addExpansion(snippetExpansion)
|
||||||
|
},
|
||||||
|
|
||||||
|
textChanged (editor, event) {
|
||||||
|
const store = this.getStore(editor)
|
||||||
|
const activeExpansions = store.getExpansions()
|
||||||
|
|
||||||
|
if ((activeExpansions.length === 0) || activeExpansions[0].isIgnoringBufferChanges) { return }
|
||||||
|
|
||||||
|
this.ignoringTextChangesForEditor(editor, () =>
|
||||||
|
editor.transact(() =>
|
||||||
|
activeExpansions.map(expansion => expansion.textChanged(event)))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create a checkpoint here to consolidate all the changes we just made into
|
||||||
|
// the transaction that prompted them.
|
||||||
|
this.makeCheckpoint(editor)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Perform an action inside the editor without triggering our `textChanged`
|
||||||
|
// callback.
|
||||||
|
ignoringTextChangesForEditor (editor, callback) {
|
||||||
|
this.stopObservingEditor(editor)
|
||||||
|
callback()
|
||||||
|
this.observeEditor(editor)
|
||||||
|
},
|
||||||
|
|
||||||
|
observeEditor (editor) {
|
||||||
|
this.getStore(editor).observe(event => this.textChanged(editor, event))
|
||||||
|
},
|
||||||
|
|
||||||
|
stopObservingEditor (editor) {
|
||||||
|
this.getStore(editor).stopObserving()
|
||||||
|
},
|
||||||
|
|
||||||
|
makeCheckpoint (editor) {
|
||||||
|
this.getStore(editor).makeCheckpoint()
|
||||||
|
},
|
||||||
|
|
||||||
|
insert (snippet, editor, cursor, {method = null} = {}) {
|
||||||
|
if (editor == null) { editor = atom.workspace.getActiveTextEditor() }
|
||||||
|
if (cursor == null) { cursor = editor.getLastCursor() }
|
||||||
|
if (typeof snippet === 'string') {
|
||||||
|
const bodyTree = this.getBodyParser().parse(snippet)
|
||||||
|
snippet = new Snippet({id: this.snippetIdCounter++, name: '__anonymous', prefix: '', bodyTree, bodyText: snippet})
|
||||||
|
}
|
||||||
|
return new SnippetExpansion(snippet, editor, cursor, this, {method})
|
||||||
|
},
|
||||||
|
|
||||||
|
getUnparsedSnippets () {
|
||||||
|
const results = []
|
||||||
|
const iterate = sets => {
|
||||||
|
for (const item of sets) {
|
||||||
|
const newItem = _.deepClone(item)
|
||||||
|
// The atom-slick library has already parsed the `selector` property,
|
||||||
|
// so it's an AST here instead of a string. The object has a `toString`
|
||||||
|
// method that turns it back into a string. That custom behavior won't
|
||||||
|
// be preserved in the deep clone of the object, so we have to handle
|
||||||
|
// it separately.
|
||||||
|
newItem.selectorString = item.selector.toString()
|
||||||
|
results.push(newItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
iterate(this.scopedPropertyStore.propertySets)
|
||||||
|
iterate(this.disabledSnippetsScopedPropertyStore.propertySets)
|
||||||
|
return results
|
||||||
|
},
|
||||||
|
|
||||||
|
provideSnippets () {
|
||||||
|
return {
|
||||||
|
bundledSnippetsLoaded: () => this.loaded,
|
||||||
|
insertSnippet: this.insert.bind(this),
|
||||||
|
snippetsForScopes: this.parsedSnippetsForScopes.bind(this),
|
||||||
|
getUnparsedSnippets: this.getUnparsedSnippets.bind(this),
|
||||||
|
getUserSnippetsPath: this.getUserSnippetsPath.bind(this)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onUndoOrRedo (editor, event, isUndo) {
|
||||||
|
const activeExpansions = this.getExpansions(editor)
|
||||||
|
activeExpansions.forEach(expansion => expansion.onUndoOrRedo(isUndo))
|
||||||
|
}
|
||||||
|
}
|
48
packages/snippets/lib/tab-stop-list.js
Normal file
48
packages/snippets/lib/tab-stop-list.js
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
const TabStop = require('./tab-stop')
|
||||||
|
|
||||||
|
class TabStopList {
|
||||||
|
constructor (snippet) {
|
||||||
|
this.snippet = snippet
|
||||||
|
this.list = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
get length () {
|
||||||
|
return Object.keys(this.list).length
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasEndStop () {
|
||||||
|
return !!this.list[Infinity]
|
||||||
|
}
|
||||||
|
|
||||||
|
findOrCreate ({ index, snippet }) {
|
||||||
|
if (!this.list[index]) {
|
||||||
|
this.list[index] = new TabStop({ index, snippet })
|
||||||
|
}
|
||||||
|
return this.list[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
forEachIndex (iterator) {
|
||||||
|
let indices = Object.keys(this.list).sort((a1, a2) => a1 - a2)
|
||||||
|
indices.forEach(iterator)
|
||||||
|
}
|
||||||
|
|
||||||
|
getInsertions () {
|
||||||
|
let results = []
|
||||||
|
this.forEachIndex(index => {
|
||||||
|
results.push(...this.list[index].insertions)
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
toArray () {
|
||||||
|
let results = []
|
||||||
|
this.forEachIndex(index => {
|
||||||
|
let tabStop = this.list[index]
|
||||||
|
if (!tabStop.isValid()) return
|
||||||
|
results.push(tabStop)
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = TabStopList
|
61
packages/snippets/lib/tab-stop.js
Normal file
61
packages/snippets/lib/tab-stop.js
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
const {Range} = require('atom')
|
||||||
|
const Insertion = require('./insertion')
|
||||||
|
|
||||||
|
// A tab stop:
|
||||||
|
// * belongs to a snippet
|
||||||
|
// * has an index (one tab stop per index)
|
||||||
|
// * has multiple Insertions
|
||||||
|
class TabStop {
|
||||||
|
constructor ({ snippet, index, insertions }) {
|
||||||
|
this.insertions = insertions || []
|
||||||
|
Object.assign(this, { snippet, index })
|
||||||
|
}
|
||||||
|
|
||||||
|
isValid () {
|
||||||
|
let any = this.insertions.some(insertion => insertion.isTransformation())
|
||||||
|
if (!any) return true
|
||||||
|
let all = this.insertions.every(insertion => insertion.isTransformation())
|
||||||
|
// If there are any transforming insertions, there must be at least one
|
||||||
|
// non-transforming insertion to act as the primary.
|
||||||
|
return !all
|
||||||
|
}
|
||||||
|
|
||||||
|
addInsertion ({ range, substitution, references }) {
|
||||||
|
let insertion = new Insertion({ range, substitution, references })
|
||||||
|
let insertions = this.insertions
|
||||||
|
insertions.push(insertion)
|
||||||
|
insertions = insertions.sort((i1, i2) => {
|
||||||
|
return i1.range.start.compare(i2.range.start)
|
||||||
|
})
|
||||||
|
let initial = insertions.find(insertion => !insertion.isTransformation())
|
||||||
|
if (initial) {
|
||||||
|
insertions.splice(insertions.indexOf(initial), 1)
|
||||||
|
insertions.unshift(initial)
|
||||||
|
}
|
||||||
|
this.insertions = insertions
|
||||||
|
}
|
||||||
|
|
||||||
|
copyWithIndent (indent) {
|
||||||
|
let { snippet, index, insertions } = this
|
||||||
|
let newInsertions = insertions.map(insertion => {
|
||||||
|
let { range, substitution } = insertion
|
||||||
|
let newRange = Range.fromObject(range, true)
|
||||||
|
if (newRange.start.row) {
|
||||||
|
newRange.start.column += indent.length
|
||||||
|
newRange.end.column += indent.length
|
||||||
|
}
|
||||||
|
return new Insertion({
|
||||||
|
range: newRange,
|
||||||
|
substitution
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return new TabStop({
|
||||||
|
snippet,
|
||||||
|
index,
|
||||||
|
insertions: newInsertions
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = TabStop
|
235
packages/snippets/lib/variable.js
Normal file
235
packages/snippets/lib/variable.js
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
const path = require('path')
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const Replacer = require('./replacer')
|
||||||
|
const FLAGS = require('./simple-transformations')
|
||||||
|
const {remote} = require('electron')
|
||||||
|
|
||||||
|
function resolveClipboard () {
|
||||||
|
return atom.clipboard.read()
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDateResolver (dateParams) {
|
||||||
|
// TODO: I do not know if this method ever returns anything other than
|
||||||
|
// 'en-us'; I suspect it does not. But this is likely the forward-compatible
|
||||||
|
// way of doing things.
|
||||||
|
//
|
||||||
|
// On the other hand, if the output of CURRENT_* variables _did_ vary based
|
||||||
|
// on locale, we'd probably need to implement a setting to force an arbitrary
|
||||||
|
// locale. I imagine lots of people use their native language for their OS's
|
||||||
|
// locale but write code in English.
|
||||||
|
//
|
||||||
|
let locale = remote.app.getLocale()
|
||||||
|
return () => new Date().toLocaleString(locale, dateParams)
|
||||||
|
}
|
||||||
|
|
||||||
|
const RESOLVERS = {
|
||||||
|
// All the TM_-prefixed variables are part of the LSP specification for
|
||||||
|
// snippets.
|
||||||
|
'TM_SELECTED_TEXT' ({editor, selectionRange, method}) {
|
||||||
|
// When a snippet is inserted via tab trigger, the trigger is
|
||||||
|
// programmatically selected prior to snippet expansion so that it is
|
||||||
|
// consumed when the snippet body is inserted. The trigger _should not_ be
|
||||||
|
// treated as selected text. There is no way for $TM_SELECTED_TEXT to
|
||||||
|
// contain anything when a snippet is invoked via tab trigger.
|
||||||
|
if (method === 'prefix') return ''
|
||||||
|
|
||||||
|
if (!selectionRange || selectionRange.isEmpty()) return ''
|
||||||
|
return editor.getTextInBufferRange(selectionRange)
|
||||||
|
},
|
||||||
|
'TM_CURRENT_LINE' ({editor, cursor}) {
|
||||||
|
return editor.lineTextForBufferRow(cursor.getBufferRow())
|
||||||
|
},
|
||||||
|
'TM_CURRENT_WORD' ({editor, cursor}) {
|
||||||
|
return editor.getTextInBufferRange(cursor.getCurrentWordBufferRange())
|
||||||
|
},
|
||||||
|
'TM_LINE_INDEX' ({cursor}) {
|
||||||
|
return `${cursor.getBufferRow()}`
|
||||||
|
},
|
||||||
|
'TM_LINE_NUMBER' ({cursor}) {
|
||||||
|
return `${cursor.getBufferRow() + 1}`
|
||||||
|
},
|
||||||
|
'TM_FILENAME' ({editor}) {
|
||||||
|
return editor.getTitle()
|
||||||
|
},
|
||||||
|
'TM_FILENAME_BASE' ({editor}) {
|
||||||
|
let fileName = editor.getTitle()
|
||||||
|
if (!fileName) { return undefined }
|
||||||
|
|
||||||
|
const index = fileName.lastIndexOf('.')
|
||||||
|
if (index >= 0) {
|
||||||
|
return fileName.slice(0, index)
|
||||||
|
}
|
||||||
|
return fileName
|
||||||
|
},
|
||||||
|
'TM_FILEPATH' ({editor}) {
|
||||||
|
return editor.getPath()
|
||||||
|
},
|
||||||
|
'TM_DIRECTORY' ({editor}) {
|
||||||
|
const filePath = editor.getPath()
|
||||||
|
if (filePath === undefined) return undefined
|
||||||
|
return path.dirname(filePath)
|
||||||
|
},
|
||||||
|
|
||||||
|
// VSCode supports these.
|
||||||
|
'CLIPBOARD': resolveClipboard,
|
||||||
|
|
||||||
|
'CURRENT_YEAR': makeDateResolver({year: 'numeric'}),
|
||||||
|
'CURRENT_YEAR_SHORT': makeDateResolver({year: '2-digit'}),
|
||||||
|
'CURRENT_MONTH': makeDateResolver({month: '2-digit'}),
|
||||||
|
'CURRENT_MONTH_NAME': makeDateResolver({month: 'long'}),
|
||||||
|
'CURRENT_MONTH_NAME_SHORT': makeDateResolver({month: 'short'}),
|
||||||
|
'CURRENT_DATE': makeDateResolver({day: '2-digit'}),
|
||||||
|
'CURRENT_DAY_NAME': makeDateResolver({weekday: 'long'}),
|
||||||
|
'CURRENT_DAY_NAME_SHORT': makeDateResolver({weekday: 'short'}),
|
||||||
|
'CURRENT_HOUR': makeDateResolver({hour12: false, hour: '2-digit'}),
|
||||||
|
'CURRENT_MINUTE': makeDateResolver({minute: '2-digit'}),
|
||||||
|
'CURRENT_SECOND': makeDateResolver({second: '2-digit'}),
|
||||||
|
'CURRENT_SECONDS_UNIX': () => {
|
||||||
|
return Math.floor( Date.now() / 1000 )
|
||||||
|
},
|
||||||
|
|
||||||
|
// NOTE: "Ancestor project path" is determined as follows:
|
||||||
|
//
|
||||||
|
// * Get all project paths via `atom.project.getPaths()`.
|
||||||
|
// * Return the first path (in the order we received) that is an ancestor of
|
||||||
|
// the current file in the editor.
|
||||||
|
|
||||||
|
// The current file's path relative to the ancestor project path.
|
||||||
|
'RELATIVE_FILEPATH' ({editor}) {
|
||||||
|
let filePath = editor.getPath()
|
||||||
|
let projectPaths = atom.project.getPaths()
|
||||||
|
if (projectPaths.length === 0) { return filePath }
|
||||||
|
// A project can have multiple path roots. Return whichever is the first
|
||||||
|
// that is an ancestor of the file path.
|
||||||
|
let ancestor = projectPaths.find(pp => {
|
||||||
|
return filePath.startsWith(`${pp}${path.sep}`)
|
||||||
|
})
|
||||||
|
if (!ancestor) return {filePath}
|
||||||
|
|
||||||
|
return filePath.substring(ancestor.length)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Last path component of the ancestor project path.
|
||||||
|
'WORKSPACE_NAME' ({editor}) {
|
||||||
|
let projectPaths = atom.project.getPaths()
|
||||||
|
if (projectPaths.length === 0) { return '' }
|
||||||
|
let filePath = editor.getPath()
|
||||||
|
let ancestor = projectPaths.find(pp => {
|
||||||
|
return filePath.startsWith(`${pp}${path.sep}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
return path.basename(ancestor)
|
||||||
|
},
|
||||||
|
|
||||||
|
// The full path to the ancestor project path.
|
||||||
|
'WORKSPACE_FOLDER' ({editor}) {
|
||||||
|
let projectPaths = atom.project.getPaths()
|
||||||
|
if (projectPaths.length === 0) { return '' }
|
||||||
|
let filePath = editor.getPath()
|
||||||
|
let ancestor = projectPaths.find(pp => {
|
||||||
|
return filePath.startsWith(`${pp}${path.sep}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
return ancestor
|
||||||
|
},
|
||||||
|
|
||||||
|
'CURSOR_INDEX' ({editor, cursor}) {
|
||||||
|
let cursors = editor.getCursors()
|
||||||
|
let index = cursors.indexOf(cursor)
|
||||||
|
return index >= 0 ? String(index) : ''
|
||||||
|
},
|
||||||
|
|
||||||
|
'CURSOR_NUMBER' ({editor, cursor}) {
|
||||||
|
let cursors = editor.getCursors()
|
||||||
|
let index = cursors.indexOf(cursor)
|
||||||
|
return index >= 0 ? String(index + 1) : ''
|
||||||
|
},
|
||||||
|
|
||||||
|
'RANDOM' () {
|
||||||
|
return Math.random().toString().slice(-6)
|
||||||
|
},
|
||||||
|
|
||||||
|
'RANDOM_HEX' () {
|
||||||
|
return Math.random().toString(16).slice(-6)
|
||||||
|
},
|
||||||
|
|
||||||
|
'BLOCK_COMMENT_START' ({editor, cursor}) {
|
||||||
|
let delimiters = editor.getCommentDelimitersForBufferPosition(
|
||||||
|
cursor.getBufferPosition()
|
||||||
|
)
|
||||||
|
return (delimiters?.block?.[0] ?? '').trim()
|
||||||
|
},
|
||||||
|
|
||||||
|
'BLOCK_COMMENT_END' ({editor, cursor}) {
|
||||||
|
let delimiters = editor.getCommentDelimitersForBufferPosition(
|
||||||
|
cursor.getBufferPosition()
|
||||||
|
)
|
||||||
|
return (delimiters?.block?.[1] ?? '').trim()
|
||||||
|
},
|
||||||
|
|
||||||
|
'LINE_COMMENT' ({editor, cursor}) {
|
||||||
|
let delimiters = editor.getCommentDelimitersForBufferPosition(
|
||||||
|
cursor.getBufferPosition()
|
||||||
|
)
|
||||||
|
return (delimiters?.line ?? '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: VSCode also supports:
|
||||||
|
//
|
||||||
|
// UUID
|
||||||
|
//
|
||||||
|
// (can be done without dependencies once we use Node >= 14.17.0 or >=
|
||||||
|
// 15.6.0; see below)
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
// $UUID will be easy to implement once Pulsar runs a newer version of Node, so
|
||||||
|
// there's no reason not to be proactive and sniff for the function we need.
|
||||||
|
if (('randomUUID' in crypto) && (typeof crypto.randomUUID === 'function')) {
|
||||||
|
RESOLVERS['UUID'] = () => {
|
||||||
|
return crypto.randomUUID({disableEntropyCache: true})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function replaceByFlag (text, flag) {
|
||||||
|
let replacer = FLAGS[flag]
|
||||||
|
if (!replacer) { return text }
|
||||||
|
return replacer(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Variable {
|
||||||
|
constructor ({point, snippet, variable: name, substitution}) {
|
||||||
|
Object.assign(this, {point, snippet, name, substitution})
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve (params) {
|
||||||
|
let base = ''
|
||||||
|
if (this.name in RESOLVERS) {
|
||||||
|
base = RESOLVERS[this.name](params)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.substitution) {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
let {flag, find, replace} = this.substitution
|
||||||
|
|
||||||
|
// Two kinds of substitution.
|
||||||
|
if (flag) {
|
||||||
|
// This is the kind with the trailing `:/upcase`, `:/downcase`, etc.
|
||||||
|
return replaceByFlag(base, flag)
|
||||||
|
} else if (find && replace) {
|
||||||
|
// This is the more complex sed-style substitution.
|
||||||
|
let {find, replace} = this.substitution
|
||||||
|
this.replacer ??= new Replacer(replace)
|
||||||
|
return base.replace(find, (...args) => {
|
||||||
|
return this.replacer.replace(...args)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Variable
|
12
packages/snippets/menus/snippets.cson
Normal file
12
packages/snippets/menus/snippets.cson
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
'menu': [
|
||||||
|
'label': 'Packages'
|
||||||
|
'submenu': [
|
||||||
|
'label': 'Snippets'
|
||||||
|
'submenu': [
|
||||||
|
{ 'label': 'Expand', 'command': 'snippets:show' }
|
||||||
|
{ 'label': 'Next Stop', 'command': 'snippets:next-tab-stop' }
|
||||||
|
{ 'label': 'Previous Stop', 'command': 'snippets:previous-tab-stop' }
|
||||||
|
{ 'label': 'Available', 'command': 'snippets:available' }
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
2574
packages/snippets/package-lock.json
generated
Normal file
2574
packages/snippets/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
packages/snippets/package.json
Normal file
31
packages/snippets/package.json
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "snippets",
|
||||||
|
"version": "1.8.0",
|
||||||
|
"main": "./lib/snippets",
|
||||||
|
"description": "Expand snippets matching the current prefix with `tab`.",
|
||||||
|
"repository": "https://github.com/pulsar-edit/pulsar",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"atom": "*"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"async": "~0.2.6",
|
||||||
|
"atom-select-list": "^0.7.0",
|
||||||
|
"pegjs": "^0.10.0",
|
||||||
|
"scoped-property-store": "^0.17.0",
|
||||||
|
"season": "^6.0.2",
|
||||||
|
"temp": "~0.8.0",
|
||||||
|
"underscore-plus": "^1.0.0"
|
||||||
|
},
|
||||||
|
"providedServices": {
|
||||||
|
"snippets": {
|
||||||
|
"description": "Snippets are text shortcuts that can be expanded to their definition.",
|
||||||
|
"versions": {
|
||||||
|
"0.1.0": "provideSnippets"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"eslint": "^8.35.0"
|
||||||
|
}
|
||||||
|
}
|
5
packages/snippets/spec/.eslintrc
Normal file
5
packages/snippets/spec/.eslintrc
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"rules": {
|
||||||
|
"semi": ["error", "always"]
|
||||||
|
}
|
||||||
|
}
|
704
packages/snippets/spec/body-parser-spec.js
Normal file
704
packages/snippets/spec/body-parser-spec.js
Normal file
@ -0,0 +1,704 @@
|
|||||||
|
const BodyParser = require('../lib/snippet-body-parser');
|
||||||
|
|
||||||
|
function expectMatch (input, tree) {
|
||||||
|
expect(BodyParser.parse(input)).toEqual(tree);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Snippet Body Parser", () => {
|
||||||
|
it("parses a snippet with no special behavior", () => {
|
||||||
|
const bodyTree = BodyParser.parse('${} $ n $}1} ${/upcase/} \n world ${||}');
|
||||||
|
expect(bodyTree).toEqual([
|
||||||
|
'${} $ n $}1} ${/upcase/} \n world ${||}'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('for snippets with variables', () => {
|
||||||
|
it('parses simple variables', () => {
|
||||||
|
expectMatch('$f_o_0', [{variable: 'f_o_0'}]);
|
||||||
|
expectMatch('$_FOO', [{variable: '_FOO'}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses verbose variables', () => {
|
||||||
|
expectMatch('${foo}', [{variable: 'foo'}]);
|
||||||
|
expectMatch('${FOO}', [{variable: 'FOO'}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses variables with placeholders', () => {
|
||||||
|
expectMatch(
|
||||||
|
'${f:placeholder}',
|
||||||
|
[{variable: 'f', content: ['placeholder']}]
|
||||||
|
);
|
||||||
|
|
||||||
|
expectMatch(
|
||||||
|
'${f:foo$1 $VAR}',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
variable: 'f',
|
||||||
|
content: [
|
||||||
|
'foo',
|
||||||
|
{index: 1, content: []},
|
||||||
|
' ',
|
||||||
|
{variable: 'VAR'}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Allows a colon as part of the placeholder value.
|
||||||
|
expectMatch(
|
||||||
|
'${TM_SELECTED_TEXT:foo:bar}',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
variable: 'TM_SELECTED_TEXT',
|
||||||
|
content: [
|
||||||
|
'foo:bar'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses simple transformations like /upcase', () => {
|
||||||
|
const bodyTree = BodyParser.parse("lorem ipsum ${CLIPBOARD:/upcase} dolor sit amet");
|
||||||
|
expectMatch(
|
||||||
|
"lorem ipsum ${CLIPBOARD:/upcase} dolor sit amet",
|
||||||
|
[
|
||||||
|
"lorem ipsum ",
|
||||||
|
{
|
||||||
|
variable: 'CLIPBOARD',
|
||||||
|
substitution: {flag: 'upcase'}
|
||||||
|
},
|
||||||
|
" dolor sit amet"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses variables with transforms', () => {
|
||||||
|
expectMatch('${f/.*/$0/}', [
|
||||||
|
{
|
||||||
|
variable: 'f',
|
||||||
|
substitution: {
|
||||||
|
find: /.*/,
|
||||||
|
replace: [
|
||||||
|
{backreference: 0}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe('for snippets with tabstops', () => {
|
||||||
|
it('parses simple tabstops', () => {
|
||||||
|
expectMatch('hello$1world$2', [
|
||||||
|
'hello',
|
||||||
|
{index: 1, content: []},
|
||||||
|
'world',
|
||||||
|
{index: 2, content: []}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses verbose tabstops', () => {
|
||||||
|
expectMatch('hello${1}world${2}', [
|
||||||
|
'hello',
|
||||||
|
{index: 1, content: []},
|
||||||
|
'world',
|
||||||
|
{index: 2, content: []}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips escaped tabstops', () => {
|
||||||
|
expectMatch('$1 \\$2 $3 \\\\$4 \\\\\\$5 $6', [
|
||||||
|
{index: 1, content: []},
|
||||||
|
' $2 ',
|
||||||
|
{index: 3, content: []},
|
||||||
|
' \\',
|
||||||
|
{index: 4, content: []},
|
||||||
|
' \\$5 ',
|
||||||
|
{index: 6, content: []}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('for tabstops with placeholders', () => {
|
||||||
|
it('parses them', () => {
|
||||||
|
expectMatch('hello${1:placeholder}world', [
|
||||||
|
'hello',
|
||||||
|
{index: 1, content: ['placeholder']},
|
||||||
|
'world'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows escaped back braces', () => {
|
||||||
|
expectMatch('${1:{}}', [
|
||||||
|
{index: 1, content: ['{']},
|
||||||
|
'}'
|
||||||
|
]);
|
||||||
|
expectMatch('${1:{\\}}', [
|
||||||
|
{index: 1, content: ['{}']}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses tabstops with transforms', () => {
|
||||||
|
expectMatch('${1/.*/$0/}', [
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
content: [],
|
||||||
|
substitution: {
|
||||||
|
find: /.*/,
|
||||||
|
replace: [{backreference: 0}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses tabstops with choices', () => {
|
||||||
|
expectMatch('${1|on}e,t\\|wo,th\\,ree|}', [
|
||||||
|
{index: 1, content: ['on}e'], choice: ['on}e', 't|wo', 'th,ree']}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses if-else syntax', () => {
|
||||||
|
expectMatch(
|
||||||
|
'$1 ${1/(?:(wat)|^.*$)$/${1:+hey}/}',
|
||||||
|
[
|
||||||
|
{index: 1, content: []},
|
||||||
|
" ",
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
content: [],
|
||||||
|
substitution: {
|
||||||
|
find: /(?:(wat)|^.*$)$/,
|
||||||
|
replace: [
|
||||||
|
{
|
||||||
|
backreference: 1,
|
||||||
|
iftext: "hey",
|
||||||
|
elsetext: ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
expectMatch(
|
||||||
|
'$1 ${1/(?:(wat)|^.*$)$/${1:?hey:nah}/}',
|
||||||
|
[
|
||||||
|
{index: 1, content: []},
|
||||||
|
" ",
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
content: [],
|
||||||
|
substitution: {
|
||||||
|
find: /(?:(wat)|^.*$)$/,
|
||||||
|
replace: [
|
||||||
|
{
|
||||||
|
backreference: 1,
|
||||||
|
iftext: "hey",
|
||||||
|
elsetext: "nah"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// else with `:` syntax
|
||||||
|
expectMatch(
|
||||||
|
'$1 ${1/(?:(wat)|^.*$)$/${1:fallback}/}',
|
||||||
|
[
|
||||||
|
{index: 1, content: []},
|
||||||
|
" ",
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
content: [],
|
||||||
|
substitution: {
|
||||||
|
find: /(?:(wat)|^.*$)$/,
|
||||||
|
replace: [
|
||||||
|
{
|
||||||
|
backreference: 1,
|
||||||
|
iftext: "",
|
||||||
|
elsetext: "fallback"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
// else with `:-` syntax; should be same as above
|
||||||
|
expectMatch(
|
||||||
|
'$1 ${1/(?:(wat)|^.*$)$/${1:-fallback}/}',
|
||||||
|
[
|
||||||
|
{index: 1, content: []},
|
||||||
|
" ",
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
content: [],
|
||||||
|
substitution: {
|
||||||
|
find: /(?:(wat)|^.*$)$/,
|
||||||
|
replace: [
|
||||||
|
{
|
||||||
|
backreference: 1,
|
||||||
|
iftext: "",
|
||||||
|
elsetext: "fallback"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses alternative if-else syntax', () => {
|
||||||
|
expectMatch(
|
||||||
|
'$1 ${1/(?:(wat)|^.*$)$/(?1:hey:)/}',
|
||||||
|
[
|
||||||
|
{index: 1, content: []},
|
||||||
|
" ",
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
content: [],
|
||||||
|
substitution: {
|
||||||
|
find: /(?:(wat)|^.*$)$/,
|
||||||
|
replace: [
|
||||||
|
{
|
||||||
|
backreference: 1,
|
||||||
|
iftext: ["hey"],
|
||||||
|
elsetext: ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
expectMatch(
|
||||||
|
'$1 ${1/(?:(wat)|^.*$)$/(?1:\\u$1:)/}',
|
||||||
|
[
|
||||||
|
{index: 1, content: []},
|
||||||
|
" ",
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
content: [],
|
||||||
|
substitution: {
|
||||||
|
find: /(?:(wat)|^.*$)$/,
|
||||||
|
replace: [
|
||||||
|
{
|
||||||
|
backreference: 1,
|
||||||
|
iftext: [
|
||||||
|
{escape: 'u'},
|
||||||
|
{backreference: 1}
|
||||||
|
],
|
||||||
|
elsetext: ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
expectMatch(
|
||||||
|
'$1 ${1/(?:(wat)|^.*$)$/(?1::hey)/}',
|
||||||
|
[
|
||||||
|
{index: 1, content: []},
|
||||||
|
" ",
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
content: [],
|
||||||
|
substitution: {
|
||||||
|
find: /(?:(wat)|^.*$)$/,
|
||||||
|
replace: [
|
||||||
|
{
|
||||||
|
backreference: 1,
|
||||||
|
iftext: "",
|
||||||
|
elsetext: ["hey"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
expectMatch(
|
||||||
|
'class ${1:${TM_FILENAME/(?:\\A|_)([A-Za-z0-9]+)(?:\\.rb)?/(?2::\\u$1)/g}} < ${2:Application}Controller\n $3\nend',
|
||||||
|
[
|
||||||
|
'class ',
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
variable: 'TM_FILENAME',
|
||||||
|
substitution: {
|
||||||
|
find: /(?:\A|_)([A-Za-z0-9]+)(?:\.rb)?/g,
|
||||||
|
replace: [
|
||||||
|
{
|
||||||
|
backreference: 2,
|
||||||
|
iftext: '',
|
||||||
|
elsetext: [
|
||||||
|
{escape: 'u'},
|
||||||
|
{backreference: 1}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
' < ',
|
||||||
|
{
|
||||||
|
index: 2,
|
||||||
|
content: ['Application']
|
||||||
|
},
|
||||||
|
'Controller\n ',
|
||||||
|
{index: 3, content : []},
|
||||||
|
'\nend'
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recognizes escape characters in if/else syntax', () => {
|
||||||
|
|
||||||
|
expectMatch(
|
||||||
|
'$1 ${1/(?:(wat)|^.*$)$/${1:?hey\\:hey:nah}/}',
|
||||||
|
[
|
||||||
|
{index: 1, content: []},
|
||||||
|
" ",
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
content: [],
|
||||||
|
substitution: {
|
||||||
|
find: /(?:(wat)|^.*$)$/,
|
||||||
|
replace: [
|
||||||
|
{
|
||||||
|
backreference: 1,
|
||||||
|
iftext: "hey:hey",
|
||||||
|
elsetext: "nah"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
expectMatch(
|
||||||
|
'$1 ${1/(?:(wat)|^.*$)$/${1:?hey:n\\}ah}/}',
|
||||||
|
[
|
||||||
|
{index: 1, content: []},
|
||||||
|
" ",
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
content: [],
|
||||||
|
substitution: {
|
||||||
|
find: /(?:(wat)|^.*$)$/,
|
||||||
|
replace: [
|
||||||
|
{
|
||||||
|
backreference: 1,
|
||||||
|
iftext: "hey",
|
||||||
|
elsetext: "n}ah"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it('parses nested tabstops', () => {
|
||||||
|
expectMatch(
|
||||||
|
'${1:place${2:hol${3:der}}}',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
content: [
|
||||||
|
'place',
|
||||||
|
{index: 2, content: [
|
||||||
|
'hol',
|
||||||
|
{index: 3, content: ['der']}
|
||||||
|
]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
expectMatch(
|
||||||
|
'${1:${foo:${1}}}',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
variable: 'foo',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
content: []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it("breaks a snippet body into lines, with each line containing tab stops at the appropriate position", () => {
|
||||||
|
const bodyTree = BodyParser.parse(`\
|
||||||
|
the quick brown $1fox \${2:jumped \${3:over}
|
||||||
|
}the \${4:lazy} dog\
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(bodyTree).toEqual([
|
||||||
|
"the quick brown ",
|
||||||
|
{index: 1, content: []},
|
||||||
|
"fox ",
|
||||||
|
{
|
||||||
|
index: 2,
|
||||||
|
content: [
|
||||||
|
"jumped ",
|
||||||
|
{index: 3, content: ["over"]},
|
||||||
|
"\n"
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"the ",
|
||||||
|
{index: 4, content: ["lazy"]},
|
||||||
|
" dog"
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it('handles a snippet with a transformed variable', () => {
|
||||||
|
expectMatch(
|
||||||
|
'module ${1:ActiveRecord::${TM_FILENAME/(?:\\A|_)([A-Za-z0-9]+)(?:\\.rb)?/\\u$1/g}}',
|
||||||
|
[
|
||||||
|
'module ',
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
content: [
|
||||||
|
'ActiveRecord::',
|
||||||
|
{
|
||||||
|
variable: 'TM_FILENAME',
|
||||||
|
substitution: {
|
||||||
|
find: /(?:\A|_)([A-Za-z0-9]+)(?:\.rb)?/g,
|
||||||
|
replace: [
|
||||||
|
{escape: 'u'},
|
||||||
|
{backreference: 1}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips escaped tabstops", () => {
|
||||||
|
const bodyTree = BodyParser.parse("snippet $1 escaped \\$2 \\\\$3");
|
||||||
|
expect(bodyTree).toEqual([
|
||||||
|
"snippet ",
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
content: []
|
||||||
|
},
|
||||||
|
" escaped $2 \\",
|
||||||
|
{
|
||||||
|
index: 3,
|
||||||
|
content: []
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes escaped right-braces", () => {
|
||||||
|
const bodyTree = BodyParser.parse("snippet ${1:{\\}}");
|
||||||
|
expect(bodyTree).toEqual([
|
||||||
|
"snippet ",
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
content: ["{}"]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a snippet with transformations", () => {
|
||||||
|
const bodyTree = BodyParser.parse("<${1:p}>$0</${1/f/F/}>");
|
||||||
|
expect(bodyTree).toEqual([
|
||||||
|
'<',
|
||||||
|
{index: 1, content: ['p']},
|
||||||
|
'>',
|
||||||
|
{index: 0, content: []},
|
||||||
|
'</',
|
||||||
|
{index: 1, content: [], substitution: {find: /f/, replace: ['F']}},
|
||||||
|
'>'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a snippet with transformations and a global flag", () => {
|
||||||
|
const bodyTree = BodyParser.parse("<${1:p}>$0</${1/f/F/g}>");
|
||||||
|
expect(bodyTree).toEqual([
|
||||||
|
'<',
|
||||||
|
{index: 1, content: ['p']},
|
||||||
|
'>',
|
||||||
|
{index: 0, content: []},
|
||||||
|
'</',
|
||||||
|
{index: 1, content: [], substitution: {find: /f/g, replace: ['F']}},
|
||||||
|
'>'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a snippet with multiple tab stops with transformations", () => {
|
||||||
|
const bodyTree = BodyParser.parse("${1:placeholder} ${1/(.)/\\u$1/g} $1 ${2:ANOTHER} ${2/^(.*)$/\\L$1/} $2");
|
||||||
|
expect(bodyTree).toEqual([
|
||||||
|
{index: 1, content: ['placeholder']},
|
||||||
|
' ',
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
content: [],
|
||||||
|
substitution: {
|
||||||
|
find: /(.)/g,
|
||||||
|
replace: [
|
||||||
|
{escape: 'u'},
|
||||||
|
{backreference: 1}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
' ',
|
||||||
|
{index: 1, content: []},
|
||||||
|
' ',
|
||||||
|
{index: 2, content: ['ANOTHER']},
|
||||||
|
' ',
|
||||||
|
{
|
||||||
|
index: 2,
|
||||||
|
content: [],
|
||||||
|
substitution: {
|
||||||
|
find: /^(.*)$/,
|
||||||
|
replace: [
|
||||||
|
{escape: 'L'},
|
||||||
|
{backreference: 1}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
' ',
|
||||||
|
{index: 2, content: []},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it("parses a snippet with transformations and mirrors", () => {
|
||||||
|
const bodyTree = BodyParser.parse("${1:placeholder}\n${1/(.)/\\u$1/g}\n$1");
|
||||||
|
expect(bodyTree).toEqual([
|
||||||
|
{index: 1, content: ['placeholder']},
|
||||||
|
'\n',
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
content: [],
|
||||||
|
substitution: {
|
||||||
|
find: /(.)/g,
|
||||||
|
replace: [
|
||||||
|
{escape: 'u'},
|
||||||
|
{backreference: 1}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'\n',
|
||||||
|
{index: 1, content: []}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a snippet with a format string and case-control flags", () => {
|
||||||
|
const bodyTree = BodyParser.parse("<${1:p}>$0</${1/(.)(.*)/\\u$1$2/g}>");
|
||||||
|
expect(bodyTree).toEqual([
|
||||||
|
'<',
|
||||||
|
{index: 1, content: ['p']},
|
||||||
|
'>',
|
||||||
|
{index: 0, content: []},
|
||||||
|
'</',
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
content: [],
|
||||||
|
substitution: {
|
||||||
|
find: /(.)(.*)/g,
|
||||||
|
replace: [
|
||||||
|
{escape: 'u'},
|
||||||
|
{backreference: 1},
|
||||||
|
{backreference: 2}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'>'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a snippet with an escaped forward slash in a transform", () => {
|
||||||
|
// Annoyingly, a forward slash needs to be double-backslashed just like the
|
||||||
|
// other escapes.
|
||||||
|
const bodyTree = BodyParser.parse("<${1:p}>$0</${1/(.)\\/(.*)/\\u$1$2/g}>");
|
||||||
|
expect(bodyTree).toEqual([
|
||||||
|
'<',
|
||||||
|
{index: 1, content: ['p']},
|
||||||
|
'>',
|
||||||
|
{index: 0, content: []},
|
||||||
|
'</',
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
content: [],
|
||||||
|
substitution: {
|
||||||
|
find: /(.)\/(.*)/g,
|
||||||
|
replace: [
|
||||||
|
{escape: 'u'},
|
||||||
|
{backreference: 1},
|
||||||
|
{backreference: 2}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'>'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a snippet with a placeholder that mirrors another tab stop's content", () => {
|
||||||
|
const bodyTree = BodyParser.parse("$4console.${3:log}('${2:$1}', $1);$0");
|
||||||
|
expect(bodyTree).toEqual([
|
||||||
|
{index: 4, content: []},
|
||||||
|
'console.',
|
||||||
|
{index: 3, content: ['log']},
|
||||||
|
'(\'',
|
||||||
|
{
|
||||||
|
index: 2, content: [
|
||||||
|
{index: 1, content: []}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'\', ',
|
||||||
|
{index: 1, content: []},
|
||||||
|
');',
|
||||||
|
{index: 0, content: []}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a snippet with a placeholder that mixes text and tab stop references", () => {
|
||||||
|
const bodyTree = BodyParser.parse("$4console.${3:log}('${2:uh $1}', $1);$0");
|
||||||
|
expect(bodyTree).toEqual([
|
||||||
|
{index: 4, content: []},
|
||||||
|
'console.',
|
||||||
|
{index: 3, content: ['log']},
|
||||||
|
'(\'',
|
||||||
|
{
|
||||||
|
index: 2, content: [
|
||||||
|
'uh ',
|
||||||
|
{index: 1, content: []}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'\', ',
|
||||||
|
{index: 1, content: []},
|
||||||
|
');',
|
||||||
|
{index: 0, content: []}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
1
packages/snippets/spec/fixtures/package-with-broken-snippets/snippets/.hidden-file
vendored
Normal file
1
packages/snippets/spec/fixtures/package-with-broken-snippets/snippets/.hidden-file
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
I am hidden so I shouldn't be loaded
|
1
packages/snippets/spec/fixtures/package-with-broken-snippets/snippets/invalid.json
vendored
Normal file
1
packages/snippets/spec/fixtures/package-with-broken-snippets/snippets/invalid.json
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
I am not a valid JSON file but that shouldn't cause a crisis
|
1
packages/snippets/spec/fixtures/package-with-snippets/snippets/.hidden-file
vendored
Normal file
1
packages/snippets/spec/fixtures/package-with-snippets/snippets/.hidden-file
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
This is a hidden file. Don't even try to load it as a snippet
|
1
packages/snippets/spec/fixtures/package-with-snippets/snippets/junk-file
vendored
Normal file
1
packages/snippets/spec/fixtures/package-with-snippets/snippets/junk-file
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
This file isn't CSON, but shouldn't be a big deal
|
31
packages/snippets/spec/fixtures/package-with-snippets/snippets/test.cson
vendored
Normal file
31
packages/snippets/spec/fixtures/package-with-snippets/snippets/test.cson
vendored
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
".test":
|
||||||
|
"Test Snippet":
|
||||||
|
prefix: "test"
|
||||||
|
body: "testing 123"
|
||||||
|
"Test Snippet With Description":
|
||||||
|
prefix: "testd"
|
||||||
|
body: "testing 456"
|
||||||
|
description: "a description"
|
||||||
|
descriptionMoreURL: "http://google.com"
|
||||||
|
"Test Snippet With A Label On The Left":
|
||||||
|
prefix: "testlabelleft"
|
||||||
|
body: "testing 456"
|
||||||
|
leftLabel: "a label"
|
||||||
|
"Test Snippet With HTML Labels":
|
||||||
|
prefix: "testhtmllabels"
|
||||||
|
body: "testing 456"
|
||||||
|
leftLabelHTML: "<span style=\"color:red\">Label</span>"
|
||||||
|
rightLabelHTML: "<span style=\"color:white\">Label</span>"
|
||||||
|
|
||||||
|
".package-with-snippets-unique-scope":
|
||||||
|
"Test Snippet":
|
||||||
|
prefix: "test"
|
||||||
|
body: "testing 123"
|
||||||
|
|
||||||
|
".source.js":
|
||||||
|
"Overrides a core package's snippet":
|
||||||
|
prefix: "log"
|
||||||
|
body: "from-a-community-package"
|
||||||
|
"Maps to a command":
|
||||||
|
body: 'lorem ipsum $0 dolor sit amet'
|
||||||
|
command: 'test-command-name'
|
13
packages/snippets/spec/fixtures/sample.js
vendored
Normal file
13
packages/snippets/spec/fixtures/sample.js
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
var quicksort = function () {
|
||||||
|
var sort = function(items) {
|
||||||
|
if (items.length <= 1) return items;
|
||||||
|
var pivot = items.shift(), current, left = [], right = [];
|
||||||
|
while(items.length > 0) {
|
||||||
|
current = items.shift();
|
||||||
|
current < pivot ? left.push(current) : right.push(current);
|
||||||
|
}
|
||||||
|
return sort(left).concat(pivot).concat(sort(right));
|
||||||
|
};
|
||||||
|
|
||||||
|
return sort(Array.apply(this, arguments));
|
||||||
|
};
|
134
packages/snippets/spec/insertion-spec.js
Normal file
134
packages/snippets/spec/insertion-spec.js
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
const Insertion = require('../lib/insertion')
|
||||||
|
const { Range } = require('atom')
|
||||||
|
|
||||||
|
const range = new Range(0, 0)
|
||||||
|
|
||||||
|
describe('Insertion', () => {
|
||||||
|
it('returns what it was given when it has no substitution', () => {
|
||||||
|
let insertion = new Insertion({
|
||||||
|
range,
|
||||||
|
substitution: undefined
|
||||||
|
})
|
||||||
|
let transformed = insertion.transform('foo!')
|
||||||
|
|
||||||
|
expect(transformed).toEqual('foo!')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('transforms what it was given when it has a regex transformation', () => {
|
||||||
|
let insertion = new Insertion({
|
||||||
|
range,
|
||||||
|
substitution: {
|
||||||
|
find: /foo/g,
|
||||||
|
replace: ['bar']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
let transformed = insertion.transform('foo!')
|
||||||
|
|
||||||
|
expect(transformed).toEqual('bar!')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('transforms the case of the next character when encountering a \\u or \\l flag', () => {
|
||||||
|
let uInsertion = new Insertion({
|
||||||
|
range,
|
||||||
|
substitution: {
|
||||||
|
find: /(.)(.)(.*)/g,
|
||||||
|
replace: [
|
||||||
|
{ backreference: 1 },
|
||||||
|
{ escape: 'u' },
|
||||||
|
{ backreference: 2 },
|
||||||
|
{ backreference: 3 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(uInsertion.transform('foo!')).toEqual('fOo!')
|
||||||
|
expect(uInsertion.transform('fOo!')).toEqual('fOo!')
|
||||||
|
expect(uInsertion.transform('FOO!')).toEqual('FOO!')
|
||||||
|
|
||||||
|
let lInsertion = new Insertion({
|
||||||
|
range,
|
||||||
|
substitution: {
|
||||||
|
find: /(.{2})(.)(.*)/g,
|
||||||
|
replace: [
|
||||||
|
{ backreference: 1 },
|
||||||
|
{ escape: 'l' },
|
||||||
|
{ backreference: 2 },
|
||||||
|
{ backreference: 3 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(lInsertion.transform('FOO!')).toEqual('FOo!')
|
||||||
|
expect(lInsertion.transform('FOo!')).toEqual('FOo!')
|
||||||
|
expect(lInsertion.transform('FoO!')).toEqual('Foo!')
|
||||||
|
expect(lInsertion.transform('foo!')).toEqual('foo!')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('transforms the case of all remaining characters when encountering a \\U or \\L flag, up until it sees a \\E flag', () => {
|
||||||
|
let uInsertion = new Insertion({
|
||||||
|
range,
|
||||||
|
substitution: {
|
||||||
|
find: /(.)(.*)/,
|
||||||
|
replace: [
|
||||||
|
{ backreference: 1 },
|
||||||
|
{ escape: 'U' },
|
||||||
|
{ backreference: 2 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(uInsertion.transform('lorem ipsum!')).toEqual('lOREM IPSUM!')
|
||||||
|
expect(uInsertion.transform('lOREM IPSUM!')).toEqual('lOREM IPSUM!')
|
||||||
|
expect(uInsertion.transform('LOREM IPSUM!')).toEqual('LOREM IPSUM!')
|
||||||
|
|
||||||
|
let ueInsertion = new Insertion({
|
||||||
|
range,
|
||||||
|
substitution: {
|
||||||
|
find: /(.)(.{3})(.*)/,
|
||||||
|
replace: [
|
||||||
|
{ backreference: 1 },
|
||||||
|
{ escape: 'U' },
|
||||||
|
{ backreference: 2 },
|
||||||
|
{ escape: 'E' },
|
||||||
|
{ backreference: 3 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(ueInsertion.transform('lorem ipsum!')).toEqual('lOREm ipsum!')
|
||||||
|
expect(ueInsertion.transform('lOREm ipsum!')).toEqual('lOREm ipsum!')
|
||||||
|
expect(ueInsertion.transform('LOREM IPSUM!')).toEqual('LOREM IPSUM!')
|
||||||
|
|
||||||
|
let lInsertion = new Insertion({
|
||||||
|
range,
|
||||||
|
substitution: {
|
||||||
|
find: /(.{4})(.)(.*)/,
|
||||||
|
replace: [
|
||||||
|
{ backreference: 1 },
|
||||||
|
{ escape: 'L' },
|
||||||
|
{ backreference: 2 },
|
||||||
|
'WHAT'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(lInsertion.transform('LOREM IPSUM!')).toEqual('LOREmwhat')
|
||||||
|
|
||||||
|
let leInsertion = new Insertion({
|
||||||
|
range,
|
||||||
|
substitution: {
|
||||||
|
find: /^([A-Fa-f])(.*)(.)$/,
|
||||||
|
replace: [
|
||||||
|
{ backreference: 1 },
|
||||||
|
{ escape: 'L' },
|
||||||
|
{ backreference: 2 },
|
||||||
|
{ escape: 'E' },
|
||||||
|
{ backreference: 3 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(leInsertion.transform('LOREM IPSUM!')).toEqual('LOREM IPSUM!')
|
||||||
|
expect(leInsertion.transform('CONSECUETUR')).toEqual('ConsecuetuR')
|
||||||
|
})
|
||||||
|
})
|
345
packages/snippets/spec/snippet-loading-spec.js
Normal file
345
packages/snippets/spec/snippet-loading-spec.js
Normal file
@ -0,0 +1,345 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const temp = require('temp').track();
|
||||||
|
|
||||||
|
describe("Snippet Loading", () => {
|
||||||
|
let configDirPath, snippetsService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
configDirPath = temp.mkdirSync('atom-config-dir-');
|
||||||
|
spyOn(atom, 'getConfigDirPath').andReturn(configDirPath);
|
||||||
|
|
||||||
|
spyOn(console, 'warn');
|
||||||
|
if (atom.notifications != null) { spyOn(atom.notifications, 'addError'); }
|
||||||
|
|
||||||
|
spyOn(atom.packages, 'getLoadedPackages').andReturn([
|
||||||
|
atom.packages.loadPackage(path.join(__dirname, 'fixtures', 'package-with-snippets')),
|
||||||
|
atom.packages.loadPackage(path.join(__dirname, 'fixtures', 'package-with-broken-snippets')),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
waitsForPromise(() => Promise.resolve(atom.packages.deactivatePackages('snippets')));
|
||||||
|
runs(() => {
|
||||||
|
jasmine.unspy(atom.packages, 'getLoadedPackages');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const activateSnippetsPackage = () => {
|
||||||
|
waitsForPromise(() => atom.packages.activatePackage("snippets").then(({mainModule}) => {
|
||||||
|
snippetsService = mainModule.provideSnippets();
|
||||||
|
mainModule.loaded = false;
|
||||||
|
}));
|
||||||
|
|
||||||
|
waitsFor("all snippets to load", 3000, () => snippetsService.bundledSnippetsLoaded());
|
||||||
|
};
|
||||||
|
|
||||||
|
it("loads the bundled snippet template snippets", () => {
|
||||||
|
activateSnippetsPackage();
|
||||||
|
|
||||||
|
runs(() => {
|
||||||
|
const jsonSnippet = snippetsService.snippetsForScopes(['.source.json'])['snip'];
|
||||||
|
expect(jsonSnippet.name).toBe('Atom Snippet');
|
||||||
|
expect(jsonSnippet.prefix).toBe('snip');
|
||||||
|
expect(jsonSnippet.body).toContain('"prefix":');
|
||||||
|
expect(jsonSnippet.body).toContain('"body":');
|
||||||
|
expect(jsonSnippet.tabStopList.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const csonSnippet = snippetsService.snippetsForScopes(['.source.coffee'])['snip'];
|
||||||
|
expect(csonSnippet.name).toBe('Atom Snippet');
|
||||||
|
expect(csonSnippet.prefix).toBe('snip');
|
||||||
|
expect(csonSnippet.body).toContain("'prefix':");
|
||||||
|
expect(csonSnippet.body).toContain("'body':");
|
||||||
|
expect(csonSnippet.tabStopList.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads non-hidden snippet files from atom packages with snippets directories", () => {
|
||||||
|
activateSnippetsPackage();
|
||||||
|
|
||||||
|
runs(() => {
|
||||||
|
let snippet = snippetsService.snippetsForScopes(['.test'])['test'];
|
||||||
|
expect(snippet.prefix).toBe('test');
|
||||||
|
expect(snippet.body).toBe('testing 123');
|
||||||
|
|
||||||
|
snippet = snippetsService.snippetsForScopes(['.test'])['testd'];
|
||||||
|
expect(snippet.prefix).toBe('testd');
|
||||||
|
expect(snippet.body).toBe('testing 456');
|
||||||
|
expect(snippet.description).toBe('a description');
|
||||||
|
expect(snippet.descriptionMoreURL).toBe('http://google.com');
|
||||||
|
|
||||||
|
snippet = snippetsService.snippetsForScopes(['.test'])['testlabelleft'];
|
||||||
|
expect(snippet.prefix).toBe('testlabelleft');
|
||||||
|
expect(snippet.body).toBe('testing 456');
|
||||||
|
expect(snippet.leftLabel).toBe('a label');
|
||||||
|
|
||||||
|
snippet = snippetsService.snippetsForScopes(['.test'])['testhtmllabels'];
|
||||||
|
expect(snippet.prefix).toBe('testhtmllabels');
|
||||||
|
expect(snippet.body).toBe('testing 456');
|
||||||
|
expect(snippet.leftLabelHTML).toBe('<span style=\"color:red\">Label</span>');
|
||||||
|
expect(snippet.rightLabelHTML).toBe('<span style=\"color:white\">Label</span>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers a command if a package snippet defines one", () => {
|
||||||
|
waitsForPromise(() => {
|
||||||
|
return atom.packages.activatePackage("snippets").then(
|
||||||
|
({mainModule}) => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
mainModule.onDidLoadSnippets(resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
runs(() => {
|
||||||
|
expect(
|
||||||
|
'package-with-snippets:test-command-name' in atom.commands.registeredCommands
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs a warning if package snippets files cannot be parsed", () => {
|
||||||
|
activateSnippetsPackage();
|
||||||
|
|
||||||
|
runs(() => {
|
||||||
|
// Warn about invalid-file, but don't even try to parse a hidden file
|
||||||
|
expect(console.warn.calls.length).toBeGreaterThan(0);
|
||||||
|
expect(console.warn.mostRecentCall.args[0]).toMatch(/Error reading.*package-with-broken-snippets/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("::loadPackageSnippets(callback)", () => {
|
||||||
|
const jsPackage = () => {
|
||||||
|
const pack = atom.packages.loadPackage('language-javascript')
|
||||||
|
pack.path = path.join(
|
||||||
|
atom.getLoadSettings().resourcePath,
|
||||||
|
'node_modules', 'language-javascript'
|
||||||
|
)
|
||||||
|
return pack
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => { // simulate a list of packages where the javascript core package is returned at the end
|
||||||
|
atom.packages.getLoadedPackages.andReturn([
|
||||||
|
atom.packages.loadPackage(path.join(__dirname, 'fixtures', 'package-with-snippets')),
|
||||||
|
jsPackage()
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// NOTE: This spec will fail if you're hacking on the Pulsar source code
|
||||||
|
// with `ATOM_DEV_RESOURCE_PATH`. Just make sure it passes in CI and you'll
|
||||||
|
// be fine.
|
||||||
|
it("allows other packages to override core packages' snippets", () => {
|
||||||
|
waitsForPromise(() => atom.packages.activatePackage("language-javascript"));
|
||||||
|
|
||||||
|
activateSnippetsPackage();
|
||||||
|
|
||||||
|
runs(() => {
|
||||||
|
const snippet = snippetsService.snippetsForScopes(['.source.js'])['log'];
|
||||||
|
expect(snippet.body).toBe("from-a-community-package");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("::onDidLoadSnippets(callback)", () => {
|
||||||
|
it("invokes listeners when all snippets are loaded", () => {
|
||||||
|
let loadedCallback = null;
|
||||||
|
|
||||||
|
waitsFor("package to activate", done => atom.packages.activatePackage("snippets").then(({mainModule}) => {
|
||||||
|
mainModule.onDidLoadSnippets(loadedCallback = jasmine.createSpy('onDidLoadSnippets callback'));
|
||||||
|
done();
|
||||||
|
}));
|
||||||
|
|
||||||
|
waitsFor("onDidLoad callback to be called", () => loadedCallback.callCount > 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when ~/.atom/snippets.json exists", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fs.mkdirSync(configDirPath, {recursive: true});
|
||||||
|
fs.writeFileSync(path.join(configDirPath, 'snippets.json'), `\
|
||||||
|
{
|
||||||
|
".foo": {
|
||||||
|
"foo snippet": {
|
||||||
|
"prefix": "foo",
|
||||||
|
"body": "bar1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}\
|
||||||
|
`
|
||||||
|
);
|
||||||
|
activateSnippetsPackage();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads the snippets from that file", () => {
|
||||||
|
let snippet = null;
|
||||||
|
|
||||||
|
waitsFor(() => snippet = snippetsService.snippetsForScopes(['.foo'])['foo']);
|
||||||
|
|
||||||
|
runs(() => {
|
||||||
|
expect(snippet.name).toBe('foo snippet');
|
||||||
|
expect(snippet.prefix).toBe("foo");
|
||||||
|
expect(snippet.body).toBe("bar1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when that file changes", () => {
|
||||||
|
it("reloads the snippets", () => {
|
||||||
|
fs.mkdirSync(configDirPath, {recursive: true});
|
||||||
|
fs.writeFileSync(path.join(configDirPath, 'snippets.json'), `\
|
||||||
|
{
|
||||||
|
".foo": {
|
||||||
|
"foo snippet": {
|
||||||
|
"prefix": "foo",
|
||||||
|
"body": "bar2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}\
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
waitsFor("snippets to be changed", () => {
|
||||||
|
const snippet = snippetsService.snippetsForScopes(['.foo'])['foo'];
|
||||||
|
return snippet && snippet.body === 'bar2';
|
||||||
|
});
|
||||||
|
|
||||||
|
runs(() => {
|
||||||
|
fs.mkdirSync(configDirPath, {recursive: true});
|
||||||
|
fs.writeFileSync(path.join(configDirPath, 'snippets.json'), "");
|
||||||
|
});
|
||||||
|
|
||||||
|
waitsFor("snippets to be removed", () => !snippetsService.snippetsForScopes(['.foo'])['foo']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when ~/.atom/snippets.cson exists", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fs.mkdirSync(configDirPath, {recursive: true});
|
||||||
|
fs.writeFileSync(path.join(configDirPath, 'snippets.cson'), `\
|
||||||
|
".foo":
|
||||||
|
"foo snippet":
|
||||||
|
"prefix": "foo"
|
||||||
|
"body": "bar1"\
|
||||||
|
`
|
||||||
|
);
|
||||||
|
activateSnippetsPackage();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads the snippets from that file", () => {
|
||||||
|
let snippet = null;
|
||||||
|
|
||||||
|
waitsFor(() => snippet = snippetsService.snippetsForScopes(['.foo'])['foo']);
|
||||||
|
|
||||||
|
runs(() => {
|
||||||
|
expect(snippet.name).toBe('foo snippet');
|
||||||
|
expect(snippet.prefix).toBe("foo");
|
||||||
|
expect(snippet.body).toBe("bar1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when that file changes", () => {
|
||||||
|
it("reloads the snippets", () => {
|
||||||
|
fs.mkdirSync(configDirPath, {recursive: true});
|
||||||
|
fs.writeFileSync(path.join(configDirPath, 'snippets.cson'), `\
|
||||||
|
".foo":
|
||||||
|
"foo snippet":
|
||||||
|
"prefix": "foo"
|
||||||
|
"body": "bar2"\
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
waitsFor("snippets to be changed", () => {
|
||||||
|
const snippet = snippetsService.snippetsForScopes(['.foo'])['foo'];
|
||||||
|
return snippet && snippet.body === 'bar2';
|
||||||
|
});
|
||||||
|
|
||||||
|
runs(() => {
|
||||||
|
fs.mkdirSync(configDirPath, {recursive: true});
|
||||||
|
fs.writeFileSync(path.join(configDirPath, 'snippets.cson'), "");
|
||||||
|
});
|
||||||
|
|
||||||
|
waitsFor("snippets to be removed", () => {
|
||||||
|
const snippet = snippetsService.snippetsForScopes(['.foo'])['foo'];
|
||||||
|
return snippet == null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifies the user when the user snippets file cannot be loaded", () => {
|
||||||
|
fs.writeFileSync(path.join(configDirPath, 'snippets.cson'), '".junk":::');
|
||||||
|
|
||||||
|
activateSnippetsPackage();
|
||||||
|
|
||||||
|
runs(() => {
|
||||||
|
expect(console.warn).toHaveBeenCalled();
|
||||||
|
if (atom.notifications != null) {
|
||||||
|
expect(atom.notifications.addError).toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("packages-with-snippets-disabled feature", () => {
|
||||||
|
it("disables no snippets if the config option is empty", () => {
|
||||||
|
const originalConfig = atom.config.get('core.packagesWithSnippetsDisabled');
|
||||||
|
atom.config.set('core.packagesWithSnippetsDisabled', []);
|
||||||
|
|
||||||
|
activateSnippetsPackage();
|
||||||
|
runs(() => {
|
||||||
|
const snippets = snippetsService.snippetsForScopes(['.package-with-snippets-unique-scope']);
|
||||||
|
expect(Object.keys(snippets).length).toBe(1);
|
||||||
|
atom.config.set('core.packagesWithSnippetsDisabled', originalConfig);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still includes a disabled package's snippets in the list of unparsed snippets", () => {
|
||||||
|
let originalConfig = atom.config.get('core.packagesWithSnippetsDisabled');
|
||||||
|
atom.config.set('core.packagesWithSnippetsDisabled', []);
|
||||||
|
|
||||||
|
activateSnippetsPackage();
|
||||||
|
runs(() => {
|
||||||
|
atom.config.set('core.packagesWithSnippetsDisabled', ['package-with-snippets']);
|
||||||
|
const allSnippets = snippetsService.getUnparsedSnippets();
|
||||||
|
const scopedSnippet = allSnippets.find(s => s.selectorString === '.package-with-snippets-unique-scope');
|
||||||
|
expect(scopedSnippet).not.toBe(undefined);
|
||||||
|
atom.config.set('core.packagesWithSnippetsDisabled', originalConfig);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never loads a package's snippets when that package is disabled in config", () => {
|
||||||
|
const originalConfig = atom.config.get('core.packagesWithSnippetsDisabled');
|
||||||
|
atom.config.set('core.packagesWithSnippetsDisabled', ['package-with-snippets']);
|
||||||
|
|
||||||
|
activateSnippetsPackage();
|
||||||
|
runs(() => {
|
||||||
|
const snippets = snippetsService.snippetsForScopes(['.package-with-snippets-unique-scope']);
|
||||||
|
expect(Object.keys(snippets).length).toBe(0);
|
||||||
|
atom.config.set('core.packagesWithSnippetsDisabled', originalConfig);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unloads and/or reloads snippets from a package if the config option is changed after activation", () => {
|
||||||
|
const originalConfig = atom.config.get('core.packagesWithSnippetsDisabled');
|
||||||
|
atom.config.set('core.packagesWithSnippetsDisabled', []);
|
||||||
|
|
||||||
|
activateSnippetsPackage();
|
||||||
|
runs(() => {
|
||||||
|
let snippets = snippetsService.snippetsForScopes(['.package-with-snippets-unique-scope']);
|
||||||
|
expect(Object.keys(snippets).length).toBe(1);
|
||||||
|
|
||||||
|
// Disable it.
|
||||||
|
atom.config.set('core.packagesWithSnippetsDisabled', ['package-with-snippets']);
|
||||||
|
snippets = snippetsService.snippetsForScopes(['.package-with-snippets-unique-scope']);
|
||||||
|
expect(Object.keys(snippets).length).toBe(0);
|
||||||
|
|
||||||
|
// Re-enable it.
|
||||||
|
atom.config.set('core.packagesWithSnippetsDisabled', []);
|
||||||
|
snippets = snippetsService.snippetsForScopes(['.package-with-snippets-unique-scope']);
|
||||||
|
expect(Object.keys(snippets).length).toBe(1);
|
||||||
|
|
||||||
|
atom.config.set('core.packagesWithSnippetsDisabled', originalConfig);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
2017
packages/snippets/spec/snippets-spec.js
Normal file
2017
packages/snippets/spec/snippets-spec.js
Normal file
File diff suppressed because it is too large
Load Diff
67
packages/snippets/spec/variable-spec.js
Normal file
67
packages/snippets/spec/variable-spec.js
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
const Variable = require('../lib/variable');
|
||||||
|
const {Point} = require('atom');
|
||||||
|
|
||||||
|
describe('Variable', () => {
|
||||||
|
|
||||||
|
let fakeCursor = {
|
||||||
|
getCurrentWordBufferRange () { return true; },
|
||||||
|
getBufferRow () { return 9; },
|
||||||
|
};
|
||||||
|
|
||||||
|
let fakeSelectionRange = {
|
||||||
|
isEmpty: () => false
|
||||||
|
};
|
||||||
|
|
||||||
|
let fakeEditor = {
|
||||||
|
getTitle () { return 'foo.rb'; },
|
||||||
|
getPath () { return '/Users/pulsar/code/foo.rb'; },
|
||||||
|
getTextInBufferRange (x) {
|
||||||
|
return x === true ? 'word' : 'this text is selected';
|
||||||
|
},
|
||||||
|
lineTextForBufferRow () {
|
||||||
|
return `this may be considered an entire line for the purposes of variable tests`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let fakeParams = {editor: fakeEditor, cursor: fakeCursor, selectionRange: fakeSelectionRange};
|
||||||
|
|
||||||
|
it('resolves to the right value', () => {
|
||||||
|
const expected = {
|
||||||
|
'TM_FILENAME': 'foo.rb',
|
||||||
|
'TM_FILENAME_BASE': 'foo',
|
||||||
|
'TM_CURRENT_LINE': `this may be considered an entire line for the purposes of variable tests`,
|
||||||
|
'TM_CURRENT_WORD': 'word',
|
||||||
|
'TM_LINE_INDEX': '9',
|
||||||
|
'TM_LINE_NUMBER': '10',
|
||||||
|
'TM_DIRECTORY': '/Users/pulsar/code',
|
||||||
|
'TM_SELECTED_TEXT': 'this text is selected'
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let variable in expected) {
|
||||||
|
let vrbl = new Variable({variable});
|
||||||
|
expect(
|
||||||
|
vrbl.resolve(fakeParams)
|
||||||
|
).toEqual(expected[variable]);
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
it('transforms', () => {
|
||||||
|
let vrbl = new Variable({
|
||||||
|
variable: 'TM_FILENAME',
|
||||||
|
substitution: {
|
||||||
|
find: /(?:^|_)([A-Za-z0-9]+)(?:\.rb)?/g,
|
||||||
|
replace: [
|
||||||
|
{escape: 'u'},
|
||||||
|
{backreference: 1}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
point: new Point(0, 0),
|
||||||
|
snippet: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
vrbl.resolve({editor: fakeEditor})
|
||||||
|
).toEqual('Foo');
|
||||||
|
});
|
||||||
|
});
|
@ -8650,9 +8650,8 @@ smart-buffer@^4.0.2, smart-buffer@^4.2.0:
|
|||||||
resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae"
|
resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae"
|
||||||
integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==
|
integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==
|
||||||
|
|
||||||
"snippets@github:pulsar-edit/snippets#v1.8.0":
|
"snippets@file:./packages/snippets":
|
||||||
version "1.8.0"
|
version "1.8.0"
|
||||||
resolved "https://codeload.github.com/pulsar-edit/snippets/tar.gz/31a21d2d6c7e10756f204c2fbb7bb8d140f0744d"
|
|
||||||
dependencies:
|
dependencies:
|
||||||
async "~0.2.6"
|
async "~0.2.6"
|
||||||
atom-select-list "^0.7.0"
|
atom-select-list "^0.7.0"
|
||||||
|
Loading…
Reference in New Issue
Block a user