Merge pull request #993 from pulsar-edit/bundle-snippets

Bundle snippets
This commit is contained in:
confused_techie 2024-05-08 17:05:04 -07:00 committed by GitHub
commit 6c1f1ad697
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 11717 additions and 8 deletions

View File

@ -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",

View File

@ -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

View File

@ -0,0 +1 @@
*.pegjs

View 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
View File

@ -0,0 +1,2 @@
node_modules
.tool-versions

16
packages/snippets/.pairs Normal file
View 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

View 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
View 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 cant conflict with each other, nor can they conflict with any other commands that have been defined. If there is such a conflict, youll 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, heres 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 cant reliably be supported cross-platform, and is probably a bad idea anyway. No other editors that support snippets have adopted this feature, and Pulsar wont 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 youre 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 projects 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

View File

@ -0,0 +1,2 @@
'atom-text-editor:not([mini])':
'tab': 'snippets:expand'

View 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'

View 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

View 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, '..')
}
}

View 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

View 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

View 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

View 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

File diff suppressed because it is too large Load Diff

View 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);
}

View 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)
}
}

View 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

View 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('')
}
}

View 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()
}
}

View 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
"""

View 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 isnt 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))
}
}

View 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

View 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

View 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

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View File

@ -0,0 +1,5 @@
{
"rules": {
"semi": ["error", "always"]
}
}

View 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: []}
]);
});
});

View File

@ -0,0 +1 @@
I am hidden so I shouldn't be loaded

View File

@ -0,0 +1 @@
I am not a valid JSON file but that shouldn't cause a crisis

View File

@ -0,0 +1 @@
This is a hidden file. Don't even try to load it as a snippet

View File

@ -0,0 +1 @@
This file isn't CSON, but shouldn't be a big deal

View 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'

View 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));
};

View 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')
})
})

View 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);
});
});
});
});

File diff suppressed because it is too large Load Diff

View 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');
});
});

View File

@ -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"