mirror of
https://github.com/pulsar-edit/pulsar.git
synced 2024-08-16 14:40:23 +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",
|
||||
"settings-view": "file:packages/settings-view",
|
||||
"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-light-syntax": "file:packages/solarized-light-syntax",
|
||||
"spell-check": "file:packages/spell-check",
|
||||
@ -235,7 +235,7 @@
|
||||
"package-generator": "file:./packages/package-generator",
|
||||
"pulsar-updater": "file:./packages/pulsar-updater",
|
||||
"settings-view": "file:./packages/settings-view",
|
||||
"snippets": "1.8.0",
|
||||
"snippets": "file:./packages/snippets",
|
||||
"spell-check": "file:./packages/spell-check",
|
||||
"status-bar": "file:./packages/status-bar",
|
||||
"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) | |
|
||||
| **package-generator** | [`./package-generator`](./package-generator) | |
|
||||
| **pulsar-updater** | [`./pulsar-updater`](./pulsar-updater) | |
|
||||
| **snippets** | [`pulsar-edit/snippets`][snippets] | |
|
||||
| **snippets** | [`./snippets`](./snippets) | |
|
||||
| **solarized-dark-syntax** | [`./solarized-dark-syntax`](./solarized-dark-syntax) | |
|
||||
| **solarized-light-syntax** | [`./solarized-light-syntax`](./solarized-light-syntax) | |
|
||||
| **spell-check** | [`./spell-check`](./spell-check) | |
|
||||
| **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) | |
|
||||
| **symbols-view** | [`pulsar-edit/symbols-view`][symbols-view] | |
|
||||
| **symbols-view** | [`./symbols-view`](./symbols-view) | |
|
||||
| **tabs** | [`./tabs`](./tabs) | |
|
||||
| **timecop** | [`./timecop`](./timecop) | |
|
||||
| **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) | |
|
||||
|
||||
[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"
|
||||
integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==
|
||||
|
||||
"snippets@github:pulsar-edit/snippets#v1.8.0":
|
||||
"snippets@file:./packages/snippets":
|
||||
version "1.8.0"
|
||||
resolved "https://codeload.github.com/pulsar-edit/snippets/tar.gz/31a21d2d6c7e10756f204c2fbb7bb8d140f0744d"
|
||||
dependencies:
|
||||
async "~0.2.6"
|
||||
atom-select-list "^0.7.0"
|
||||
|
Loading…
Reference in New Issue
Block a user