mirror of
https://github.com/pulsar-edit/pulsar.git
synced 2024-08-16 22:50:22 +03:00
Merge remote-tracking branch 'origin/master' into clojure-grammar-enhancements
This commit is contained in:
commit
852483a30a
11
.cirrus.yml
11
.cirrus.yml
@ -1,6 +1,6 @@
|
||||
env:
|
||||
PYTHON_VERSION: 3.12
|
||||
GITHUB_TOKEN: ENCRYPTED[!c394f11378a8bc92ff1b05662ee3e574fc662692e45f0a048aa8cab42fb072b039d83f68fd6953f470af51846063ce46!]
|
||||
GITHUB_TOKEN: ENCRYPTED[!9e497dd40c7819a1ddd425d718f50f539f4e080143e20518fc8398b117632551aae72e2680299a57c2be9f0c15a9d2f7!]
|
||||
# The above token, is a GitHub API Token, that allows us to download RipGrep without concern of API limits
|
||||
|
||||
# linux_task:
|
||||
@ -63,7 +63,7 @@ arm_linux_task:
|
||||
memory: 8G
|
||||
env:
|
||||
USE_SYSTEM_FPM: 'true'
|
||||
ROLLING_UPLOAD_TOKEN: ENCRYPTED[f935c396a9f4bca108ec2fdedb00dbc9be2f4c411f100d577acdab42db59ea134be059ce8535396db8222a2b1eb68c27]
|
||||
ROLLING_UPLOAD_TOKEN: ENCRYPTED[b318d12e0d41475229383e74c3a98b3921024096b066d9f535ca42bfe025558eca69d1e0f56413abedcf2d5817615c4c]
|
||||
prepare_script:
|
||||
- apt-get update
|
||||
- export DEBIAN_FRONTEND="noninteractive"
|
||||
@ -72,6 +72,7 @@ arm_linux_task:
|
||||
procps
|
||||
curl
|
||||
ruby
|
||||
ruby-dev
|
||||
rpm
|
||||
build-essential
|
||||
git
|
||||
@ -131,10 +132,10 @@ silicon_mac_task:
|
||||
APPLEID: ENCRYPTED[549ce052bd5666dba5245f4180bf93b74ed206fe5e6e7c8f67a8596d3767c1f682b84e347b326ac318c62a07c8844a57]
|
||||
APPLEID_PASSWORD: ENCRYPTED[774c3307fd3b62660ecf5beb8537a24498c76e8d90d7f28e5bc816742fd8954a34ffed13f9aa2d1faf66ce08b4496e6f]
|
||||
TEAM_ID: ENCRYPTED[11f3fedfbaf4aff1859bf6c105f0437ace23d84f5420a2c1cea884fbfa43b115b7834a463516d50cb276d4c4d9128b49]
|
||||
ROLLING_UPLOAD_TOKEN: ENCRYPTED[f935c396a9f4bca108ec2fdedb00dbc9be2f4c411f100d577acdab42db59ea134be059ce8535396db8222a2b1eb68c27]
|
||||
ROLLING_UPLOAD_TOKEN: ENCRYPTED[b318d12e0d41475229383e74c3a98b3921024096b066d9f535ca42bfe025558eca69d1e0f56413abedcf2d5817615c4c]
|
||||
prepare_script:
|
||||
- brew update
|
||||
- brew uninstall node
|
||||
- brew uninstall node@20
|
||||
- brew install git python@$PYTHON_VERSION python-setuptools
|
||||
- git submodule init
|
||||
- git submodule update
|
||||
@ -199,7 +200,7 @@ silicon_mac_task:
|
||||
# - arch -x86_64 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install.sh)"
|
||||
# - export PATH="/usr/local/bin:$PATH"
|
||||
# - arch -x86_64 brew update
|
||||
# - arch -x86_64 brew uninstall node
|
||||
# - arch -x86_64 brew uninstall node@20
|
||||
# - arch -x86_64 brew install node@16 git python@$PYTHON_VERSION python-setuptools
|
||||
# - ln -s /usr/local/bin/python$PYTHON_VERSION /usr/local/bin/python
|
||||
# - npm install -g yarn
|
||||
|
2
.github/renovate.json
vendored
2
.github/renovate.json
vendored
@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": [ "config:base", ":dependencyDashboardApproval"],
|
||||
"extends": [ "config:recommended", ":dependencyDashboardApproval"],
|
||||
"constraints": {
|
||||
"node": "< 16"
|
||||
},
|
||||
|
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@ -24,7 +24,7 @@ jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ ubuntu-latest, macos-latest, windows-latest ]
|
||||
os: [ ubuntu-latest, macos-12, windows-latest ]
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
image: "debian:10"
|
||||
|
4
.github/workflows/editor-tests.yml
vendored
4
.github/workflows/editor-tests.yml
vendored
@ -15,8 +15,8 @@ jobs:
|
||||
!startsWith(github.event.pull_request.title, '[skip-editor-ci]')
|
||||
strategy:
|
||||
matrix:
|
||||
# os: [ubuntu-20.04, macos-latest, windows-2019]
|
||||
os: [ubuntu-20.04, macos-latest]
|
||||
# os: [ubuntu-20.04, macos-12, windows-2019]
|
||||
os: [ubuntu-20.04, macos-12]
|
||||
fail-fast: false
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
|
25
CHANGELOG.md
25
CHANGELOG.md
@ -18,6 +18,31 @@
|
||||
better support for "def" elements (example - don't syntax `default` or
|
||||
`definition` as a `def`, but highlights `p/defresolver`)
|
||||
|
||||
## 1.117.0
|
||||
|
||||
* [markdown-preview] Improve rendering performance in preview panes, especially in documents with lots of fenced code blocks.
|
||||
* [markdown-preview] GitHub-style Markdown preview now uses up-to-date styles and supports dark mode.
|
||||
* Pulsar's OS level theme will now change according to the selected editor theme if `core.syncWindowThemeWithPulsarTheme` is enabled.
|
||||
* [language-sass] Add SCSS Tree-sitter grammar.
|
||||
* [language-ruby] Update to latest Tree-sitter Ruby parser.
|
||||
* [language-gfm] Make each block-level HTML tag its own injection.
|
||||
* [language-typescript] More highlighting fixes, especially for operators.
|
||||
|
||||
### Pulsar
|
||||
- Fixed: Cirrus: Fix gem install fpm on ARM Linux [@DeeDeeG](https://github.com/pulsar-edit/pulsar/pull/1008)
|
||||
- Updated: [ci] Update Cirrus CI Token [@confused-Techie](https://github.com/pulsar-edit/pulsar/pull/1006)
|
||||
- Fixed: CI: Fix workaround for Homebrew node in Cirrus on macOS [@DeeDeeG](https://github.com/pulsar-edit/pulsar/pull/1002)
|
||||
- Added: [markdown-preview] Optimize re-rendering of content in a preview pane especially syntax highlighting [@savetheclocktower](https://github.com/pulsar-edit/pulsar/pull/984)
|
||||
- Fixed: Tree-sitter rolling fixes, 1.117 edition [@savetheclocktower](https://github.com/pulsar-edit/pulsar/pull/974)
|
||||
- Updated: Update Renovate preset name [@HonkingGoose](https://github.com/pulsar-edit/pulsar/pull/1000)
|
||||
- Added: Debugging when a package service is incorrect [@mauricioszabo](https://github.com/pulsar-edit/pulsar/pull/995)
|
||||
- Added: Bundle snippets [@confused-Techie](https://github.com/pulsar-edit/pulsar/pull/993)
|
||||
- Fixed: CI: Pin to macOS 12 runner images instead of macos-latest (GitHub Actions) [@DeeDeeG](https://github.com/pulsar-edit/pulsar/pull/997)
|
||||
- Added: [markdown-preview] Add dark mode for GitHub-style preview [@savetheclocktower](https://github.com/pulsar-edit/pulsar/pull/973)
|
||||
- Added: Change Window Theme with Pulsar Theme [@confused-Techie](https://github.com/pulsar-edit/pulsar/pull/545)
|
||||
- Updated: CI: Upgrade or replace all deprecated GH Actions [@DeeDeeG](https://github.com/pulsar-edit/pulsar/pull/983)
|
||||
- Fixed: [language-clojure] Stop detecting `.org` files as `.language-clojure` [@confused-Techie](https://github.com/pulsar-edit/pulsar/pull/980)
|
||||
|
||||
## 1.116.0
|
||||
|
||||
* Added `TextEditor::getCommentDelimitersForBufferPosition` for retrieving comment delimiter strings appropriate for a given buffer position. This allows us to support three new snippet variables: `LINE_COMMENT`, `BLOCK_COMMENT_START`, and `BLOCK_COMMENT_END`.
|
||||
|
@ -2,7 +2,7 @@
|
||||
"name": "pulsar",
|
||||
"author": "Pulsar-Edit <admin@pulsar-edit.dev>",
|
||||
"productName": "Pulsar",
|
||||
"version": "1.116.0-dev",
|
||||
"version": "1.117.0-dev",
|
||||
"description": "A Community-led Hyper-Hackable Text Editor",
|
||||
"branding": {
|
||||
"id": "pulsar",
|
||||
@ -159,7 +159,7 @@
|
||||
"service-hub": "^0.7.4",
|
||||
"settings-view": "file:packages/settings-view",
|
||||
"sinon": "9.2.1",
|
||||
"snippets": "github:pulsar-edit/snippets#v1.8.0",
|
||||
"snippets": "file:./packages/snippets",
|
||||
"solarized-dark-syntax": "file:packages/solarized-dark-syntax",
|
||||
"solarized-light-syntax": "file:packages/solarized-light-syntax",
|
||||
"spell-check": "file:packages/spell-check",
|
||||
@ -235,7 +235,7 @@
|
||||
"package-generator": "file:./packages/package-generator",
|
||||
"pulsar-updater": "file:./packages/pulsar-updater",
|
||||
"settings-view": "file:./packages/settings-view",
|
||||
"snippets": "1.8.0",
|
||||
"snippets": "file:./packages/snippets",
|
||||
"spell-check": "file:./packages/spell-check",
|
||||
"status-bar": "file:./packages/status-bar",
|
||||
"styleguide": "file:./packages/styleguide",
|
||||
|
@ -86,13 +86,15 @@ See [RFC 003](https://github.com/atom/atom/blob/master/docs/rfcs/003-consolidate
|
||||
| **settings-view** | [`./settings-view`](./settings-view) | |
|
||||
| **package-generator** | [`./package-generator`](./package-generator) | |
|
||||
| **pulsar-updater** | [`./pulsar-updater`](./pulsar-updater) | |
|
||||
| **snippets** | [`pulsar-edit/snippets`][snippets] | |
|
||||
| **snippets** | [`./snippets`](./snippets) | |
|
||||
| **solarized-dark-syntax** | [`./solarized-dark-syntax`](./solarized-dark-syntax) | |
|
||||
| **solarized-light-syntax** | [`./solarized-light-syntax`](./solarized-light-syntax) | |
|
||||
| **spell-check** | [`./spell-check`](./spell-check) | |
|
||||
| **status-bar** | [`./status-bar`](./status-bar) | |
|
||||
| **symbol-provider-ctags** | [`./symbol-provider-ctags`](./symbol-provider-ctags) | |
|
||||
| **symbol-provider-tree-sitter** | [`./symbol-provider-tree-sitter`](./symbol-provider-tree-sitter) | |
|
||||
| **styleguide** | [`./styleguide`](./styleguide) | |
|
||||
| **symbols-view** | [`pulsar-edit/symbols-view`][symbols-view] | |
|
||||
| **symbols-view** | [`./symbols-view`](./symbols-view) | |
|
||||
| **tabs** | [`./tabs`](./tabs) | |
|
||||
| **timecop** | [`./timecop`](./timecop) | |
|
||||
| **tree-view** | [`./tree-view`](./tree-view) | |
|
||||
@ -102,5 +104,3 @@ See [RFC 003](https://github.com/atom/atom/blob/master/docs/rfcs/003-consolidate
|
||||
| **wrap-guide** | [`./wrap-guide`](./wrap-guide) | |
|
||||
|
||||
[github]: https://github.com/pulsar-edit/github
|
||||
[snippets]: https://github.com/pulsar-edit/snippets
|
||||
[symbols-view]: https://github.com/pulsar-edit/symbols-view
|
||||
|
@ -23,13 +23,11 @@ exports.activate = () => {
|
||||
languageScope: null
|
||||
});
|
||||
|
||||
// Create one HTML injection layer for all block-level HTML nodes.
|
||||
// A separate injection layer for each block-level HTML node.
|
||||
atom.grammars.addInjectionPoint('source.gfm', {
|
||||
type: 'document',
|
||||
type: 'html_block',
|
||||
language: () => 'html',
|
||||
content(node) {
|
||||
return node.descendantsOfType('html_block');
|
||||
},
|
||||
content: (node) => node,
|
||||
includeChildren: true
|
||||
});
|
||||
|
||||
|
@ -6,7 +6,7 @@ parser: 'tree-sitter-ruby'
|
||||
injectionRegex: 'rb|ruby|RB|RUBY'
|
||||
|
||||
treeSitter:
|
||||
parserSource: 'github:tree-sitter/tree-sitter-ruby#4d9ad3f010fdc47a8433adcf9ae30c8eb8475ae7'
|
||||
parserSource: 'github:tree-sitter/tree-sitter-ruby#9d86f3761bb30e8dcc81e754b81d3ce91848477e'
|
||||
grammar: 'tree-sitter-ruby/tree-sitter-ruby.wasm'
|
||||
highlightsQuery: 'tree-sitter-ruby/highlights.scm'
|
||||
localsQuery: 'tree-sitter-ruby/locals.scm'
|
||||
|
Binary file not shown.
28
packages/language-sass/grammars/modern-tree-sitter-scss.cson
Normal file
28
packages/language-sass/grammars/modern-tree-sitter-scss.cson
Normal file
@ -0,0 +1,28 @@
|
||||
name: 'SCSS'
|
||||
scopeName: 'source.css.scss'
|
||||
type: 'modern-tree-sitter'
|
||||
# Built from the fork at savetheclocktower/tree-sitter-scss.
|
||||
parser: 'tree-sitter-scss'
|
||||
|
||||
fileTypes: [
|
||||
'scss'
|
||||
'css.scss'
|
||||
'css.scss.erb'
|
||||
'scss.erb'
|
||||
'scss.liquid'
|
||||
]
|
||||
|
||||
injectionRegex: '^(scss|SCSS)$'
|
||||
|
||||
treeSitter:
|
||||
parserSource: 'github:savetheclocktower/tree-sitter-scss#090d25a5fc829ce6956201cf55ab6b6eacad999c'
|
||||
grammar: 'tree-sitter/tree-sitter-scss.wasm'
|
||||
highlightsQuery: 'tree-sitter/highlights.scm'
|
||||
foldsQuery: 'tree-sitter/folds.scm'
|
||||
indentsQuery: 'tree-sitter/indents.scm'
|
||||
tagsQuery: 'tree-sitter/tags.scm'
|
||||
|
||||
comments:
|
||||
start: '//'
|
||||
line: '//'
|
||||
block: ['/*', '*/']
|
2
packages/language-sass/grammars/tree-sitter/folds.scm
Normal file
2
packages/language-sass/grammars/tree-sitter/folds.scm
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
(block) @fold
|
365
packages/language-sass/grammars/tree-sitter/highlights.scm
Normal file
365
packages/language-sass/grammars/tree-sitter/highlights.scm
Normal file
@ -0,0 +1,365 @@
|
||||
; WORKAROUND:
|
||||
;
|
||||
; When you're typing a new property name inside of a list, tree-sitter-css will
|
||||
; assume the thing you're typing is a descendant selector tag name until you
|
||||
; get to the colon. This prevents it from highlighting the incomplete line like
|
||||
; a selector tag name.
|
||||
|
||||
(ERROR
|
||||
(descendant_selector
|
||||
(tag_name) @_IGNORE_
|
||||
(#set! capture.final true)))
|
||||
|
||||
(ERROR
|
||||
(attribute_name) @_IGNORE_
|
||||
(#set! capture.final true))
|
||||
|
||||
((ERROR
|
||||
(attribute_name) @invalid.illegal)
|
||||
(#set! capture.final true))
|
||||
|
||||
; COMMENTS
|
||||
; ========
|
||||
|
||||
(comment) @comment.block.scss
|
||||
|
||||
; Scope the block-comment delimiters (`/*` and `*/`).
|
||||
((comment) @punctuation.definition.comment.begin.scss
|
||||
(#set! adjust.startAndEndAroundFirstMatchOf "^/\\*"))
|
||||
((comment) @punctuation.definition.comment.end.scss
|
||||
(#set! adjust.startAndEndAroundFirstMatchOf "\\*/$"))
|
||||
|
||||
(single_line_comment) @comment.line.double-slash.scss
|
||||
|
||||
((single_line_comment) @punctuation.definition.comment.scss
|
||||
(#set! adjust.startAndEndAroundFirstMatchOf "^//"))
|
||||
|
||||
|
||||
; SELECTORS
|
||||
; =========
|
||||
|
||||
; (selectors "," @punctuation.separator.list.comma.scss)
|
||||
|
||||
; The "div" in `div.foo {`.
|
||||
(tag_name) @entity.name.tag.scss
|
||||
; The "*" in `div > * {`.
|
||||
(universal_selector) @entity.name.tag.universal.scss
|
||||
; The "&" in `&:hover {`.
|
||||
(nesting_selector) @entity.name.tag.reference.scss
|
||||
|
||||
; The "foo" in `div[attr=foo] {`.
|
||||
(attribute_selector (plain_value) @string.unquoted.scss)
|
||||
|
||||
[
|
||||
(child_selector ">")
|
||||
(sibling_selector "~")
|
||||
(adjacent_sibling_selector "+")
|
||||
] @keyword.operator.combinator.scss
|
||||
|
||||
; The '.' in `.foo`.
|
||||
(class_selector "." @punctuation.definition.entity.scss)
|
||||
|
||||
; The '.foo' in `.foo`.
|
||||
((class_selector) @entity.other.attribute-name.class.scss
|
||||
(#set! adjust.startAt lastChild.previousSibling.startPosition))
|
||||
|
||||
; The '%' in `%foo`.
|
||||
(placeholder_selector "%" @punctuation.definition.entity.scss)
|
||||
|
||||
; The '%foo' in `%foo`.
|
||||
(placeholder_selector) @entity.other.attribute-name.class.scss
|
||||
|
||||
|
||||
(pseudo_class_selector [":" "::"] @punctuation.definition.entity.scss)
|
||||
|
||||
; Pseudo-classes without arguments: the ":first-of-type" in `li:first-of-type`.
|
||||
((pseudo_class_selector (class_name) (arguments) .) @entity.other.attribute-name.pseudo-class.scss
|
||||
(#set! adjust.startAt lastChild.previousSibling.previousSibling.startPosition)
|
||||
(#set! adjust.endAt lastChild.previousSibling.endPosition)
|
||||
(#set! capture.final true))
|
||||
|
||||
; Pseudo-classes with arguments: the ":nth-of-type" in `li:nth-of-type(2n-1)`.
|
||||
((pseudo_class_selector (class_name) .) @entity.other.attribute-name.pseudo-class.scss
|
||||
(#set! adjust.startAt lastChild.previousSibling.startPosition)
|
||||
(#set! adjust.endAt lastChild.endPosition))
|
||||
|
||||
(arguments
|
||||
"(" @punctuation.definition.arguments.begin.bracket.round.scss
|
||||
")" @punctuation.definition.arguments.end.bracket.round.scss)
|
||||
|
||||
(attribute_selector
|
||||
"[" @punctuation.definition.entity.begin.bracket.square.scss
|
||||
(attribute_name) @entity.other.attribute-name.scss
|
||||
"]" @punctuation.definition.entity.end.bracket.square.scss)
|
||||
|
||||
(attribute_selector
|
||||
["=" "^=" "$=" "~=" "|="] @keyword.operator.pattern.scss)
|
||||
|
||||
|
||||
; CSS VARIABLES
|
||||
; =============
|
||||
|
||||
(declaration
|
||||
(property_name) @variable.other.assignment.scss
|
||||
(#match? @variable.other.assignment.scss "^--" )
|
||||
(#set! capture.final true))
|
||||
|
||||
|
||||
; SCSS VARIABLES
|
||||
; ==============
|
||||
|
||||
(variable_name) @variable.declaration.scss
|
||||
[(variable_value)] @variable.scss
|
||||
(argument_name) @variable.parameter.scss
|
||||
(each_statement (value) @variable.declaration.scss)
|
||||
|
||||
; PROPERTIES
|
||||
; ==========
|
||||
|
||||
; TODO: Is it worth it to try to maintain a list of recognized property names?
|
||||
; Would be useful to know if you've typo'd something, but it would be a
|
||||
; maintenance headache.
|
||||
(declaration
|
||||
(property_name) @support.type.property-name.scss)
|
||||
|
||||
(important) @keyword.other.important.css.scss
|
||||
(default) @keyword.other.default.scss
|
||||
|
||||
; VALUES
|
||||
; ======
|
||||
|
||||
; Strings
|
||||
; -------
|
||||
|
||||
((string_value) @string.quoted.double.scss
|
||||
(#match? @string.quoted.double.scss "^\"")
|
||||
(#match? @string.quoted.double.scss "\"$"))
|
||||
|
||||
((string_value) @string.quoted.single.scss
|
||||
(#match? @string.quoted.single.scss "^'")
|
||||
(#match? @string.quoted.single.scss "'$"))
|
||||
|
||||
((string_value) @punctuation.definition.string.begin.scss
|
||||
(#set! adjust.startAndEndAroundFirstMatchOf "^[\"']"))
|
||||
|
||||
((string_value) @punctuation.definition.string.end.scss
|
||||
(#set! adjust.startAndEndAroundFirstMatchOf "[\"']$"))
|
||||
|
||||
|
||||
; Property value constants
|
||||
; ------------------------
|
||||
|
||||
; TODO: Is this worth it?
|
||||
((plain_value) @support.constant.property-value.scss
|
||||
(#match? @support.constant.property-value.scss "^(above|absolute|active|add|additive|after-edge|alias|all|all-petite-caps|all-scroll|all-small-caps|alpha|alphabetic|alternate|alternate-reverse|always|antialiased|auto|auto-pos|available|avoid|avoid-column|avoid-page|avoid-region|backwards|balance|baseline|before-edge|below|bevel|bidi-override|blink|block|block-axis|block-start|block-end|bold|bolder|border|border-box|both|bottom|bottom-outside|break-all|break-word|bullets|butt|capitalize|caption|cell|center|central|char|circle|clip|clone|close-quote|closest-corner|closest-side|col-resize|collapse|color|color-burn|color-dodge|column|column-reverse|common-ligatures|compact|condensed|contain|content|content-box|contents|context-menu|contextual|copy|cover|crisp-edges|crispEdges|crosshair|cyclic|dark|darken|dashed|decimal|default|dense|diagonal-fractions|difference|digits|disabled|disc|discretionary-ligatures|distribute|distribute-all-lines|distribute-letter|distribute-space|dot|dotted|double|double-circle|downleft|downright|e-resize|each-line|ease|ease-in|ease-in-out|ease-out|economy|ellipse|ellipsis|embed|end|evenodd|ew-resize|exact|exclude|exclusion|expanded|extends|extra-condensed|extra-expanded|fallback|farthest-corner|farthest-side|fill|fill-available|fill-box|filled|fit-content|fixed|flat|flex|flex-end|flex-start|flip|flow-root|forwards|freeze|from-image|full-width|geometricPrecision|georgian|grab|grabbing|grayscale|grid|groove|hand|hanging|hard-light|help|hidden|hide|historical-forms|historical-ligatures|horizontal|horizontal-tb|hue|icon|ideograph-alpha|ideograph-numeric|ideograph-parenthesis|ideograph-space|ideographic|inactive|infinite|inherit|initial|inline|inline-axis|inline-block|inline-end|inline-flex|inline-grid|inline-list-item|inline-start|inline-table|inset|inside|inter-character|inter-ideograph|inter-word|intersect|invert|isolate|isolate-override|italic|jis04|jis78|jis83|jis90|justify|justify-all|kannada|keep-all|landscape|large|larger|left|light|lighten|lighter|line|line-edge|line-through|linear|linearRGB|lining-nums|list-item|local|loose|lowercase|lr|lr-tb|ltr|luminance|luminosity|main-size|mandatory|manipulation|manual|margin-box|match-parent|match-source|mathematical|max-content|medium|menu|message-box|middle|min-content|miter|mixed|move|multiply|n-resize|narrower|ne-resize|nearest-neighbor|nesw-resize|newspaper|no-change|no-clip|no-close-quote|no-common-ligatures|no-contextual|no-discretionary-ligatures|no-drop|no-historical-ligatures|no-open-quote|no-repeat|none|nonzero|normal|not-allowed|nowrap|ns-resize|numbers|numeric|nw-resize|nwse-resize|oblique|oldstyle-nums|open|open-quote|optimizeLegibility|optimizeQuality|optimizeSpeed|optional|ordinal|outset|outside|over|overlay|overline|padding|padding-box|page|painted|pan-down|pan-left|pan-right|pan-up|pan-x|pan-y|paused|petite-caps|pixelated|plaintext|pointer|portrait|pre|pre-line|pre-wrap|preserve-3d|progress|progressive|proportional-nums|proportional-width|proximity|radial|recto|region|relative|remove|repeat|repeat-[xy]|reset-size|reverse|revert|ridge|right|rl|rl-tb|round|row|row-resize|row-reverse|row-severse|rtl|ruby|ruby-base|ruby-base-container|ruby-text|ruby-text-container|run-in|running|s-resize|saturation|scale-down|screen|scroll|scroll-position|se-resize|semi-condensed|semi-expanded|separate|sesame|show|sideways|sideways-left|sideways-lr|sideways-right|sideways-rl|simplified|slashed-zero|slice|small|small-caps|small-caption|smaller|smooth|soft-light|solid|space|space-around|space-between|space-evenly|spell-out|square|sRGB|stacked-fractions|start|static|status-bar|swap|step-end|step-start|sticky|stretch|strict|stroke|stroke-box|style|sub|subgrid|subpixel-antialiased|subtract|super|sw-resize|symbolic|table|table-caption|table-cell|table-column|table-column-group|table-footer-group|table-header-group|table-row|table-row-group|tabular-nums|tb|tb-rl|text|text-after-edge|text-before-edge|text-bottom|text-top|thick|thin|titling-caps|top|top-outside|touch|traditional|transparent|triangle|ultra-condensed|ultra-expanded|under|underline|unicase|unset|upleft|uppercase|upright|use-glyph-orientation|use-script|verso|vertical|vertical-ideographic|vertical-lr|vertical-rl|vertical-text|view-box|visible|visibleFill|visiblePainted|visibleStroke|w-resize|wait|wavy|weight|whitespace|wider|words|wrap|wrap-reverse|x|x-large|x-small|xx-large|xx-small|y|zero|zoom-in|zoom-out)$"))
|
||||
|
||||
; All property values that have special meaning in `font-family`.
|
||||
; TODO: Restrict these to be meaningful only when the property name is font-related?
|
||||
((plain_value) @support.constant.property-value.font-name.scss
|
||||
(#match? @support.constant.property-value.font-name.scss "^(serif|sans-serif|monospace|cursive|fantasy|system-ui|ui-serif|ui-sans-serif|ui-monospace|ui-rounded|emoji|math|fangsong)$"))
|
||||
|
||||
; All property values that have special meaning in `list-style-type`.
|
||||
; TODO: Restrict these to be meaningful only when the property name is `list-style-type`?
|
||||
((plain_value) @support.constant.property-value.list-style-type.scss
|
||||
(#match? @support.constant.property-value.list-style-type.scss "^(arabic-indic|armenian|bengali|cambodian|circle|cjk-decimal|cjk-earthly-branch|cjk-heavenly-stem|cjk-ideographic|decimal|decimal-leading-zero|devanagari|disc|disclosure-closed|disclosure-open|ethiopic-halehame-am|ethiopic-halehame-ti-e[rt]|ethiopic-numeric|georgian|gujarati|gurmukhi|hangul|hangul-consonant|hebrew|hiragana|hiragana-iroha|japanese-formal|japanese-informal|kannada|katakana|katakana-iroha|khmer|korean-hangul-formal|korean-hanja-formal|korean-hanja-informal|lao|lower-alpha|lower-armenian|lower-greek|lower-latin|lower-roman|malayalam|mongolian|myanmar|oriya|persian|simp-chinese-formal|simp-chinese-informal|square|tamil|telugu|thai|tibetan|trad-chinese-formal|trad-chinese-informal|upper-alpha|upper-armenian|upper-latin|upper-roman|urdu)$"))
|
||||
|
||||
; Numbers & units
|
||||
; ---------------
|
||||
|
||||
; This node type appears to always be a hex color.
|
||||
(color_value) @constant.other.color.rgb-value.hex.scss
|
||||
|
||||
[(integer_value) (float_value)] @constant.numeric.scss
|
||||
|
||||
; All unit types with valid scope names.
|
||||
((unit) @keyword.other.unit._TEXT_.scss
|
||||
(#match? @keyword.other.unit._TEXT_.scss "^(deg|grad|rad|turn|ch|cm|em|ex|fr|in|mm|mozmm|pc|pt|px|q|rem|vh|vmax|vmin|vw|dpi|dpcm|dpps|s|ms)$"))
|
||||
|
||||
((unit) @keyword.other.unit.percentage.scss
|
||||
(#eq? @keyword.other.unit.percentage.scss "%"))
|
||||
|
||||
; The magic color value `currentColor`.
|
||||
((plain_value) @support.constant.color.current.scss
|
||||
(#eq? @support.constant.color.current.scss "currentColor"))
|
||||
|
||||
; Match the TM bundle's special treatment of named colors.
|
||||
((plain_value) @support.constant.color.w3c-standard-color-name.scss
|
||||
(#match? @support.constant.color.w3c-standard-color-name.scss "^(aqua|black|blue|fuchsia|gray|green|lime|maroon|navy|olive|orange|purple|red|silver|teal|white|yellow)$"))
|
||||
|
||||
((plain_value) @support.constant.color.w3c-extended-color-name.scss
|
||||
(#match? @support.constant.color.w3c-extended-color-name.scss "^(aliceblue|antiquewhite|aquamarine|azure|beige|bisque|blanchedalmond|blueviolet|brown|burlywood|cadetblue|chartreuse|chocolate|coral|cornflowerblue|cornsilk|crimson|cyan|darkblue|darkcyan|darkgoldenrod|darkgray|darkgreen|darkgrey|darkkhaki|darkmagenta|darkolivegreen|darkorange|darkorchid|darkred|darksalmon|darkseagreen|darkslateblue|darkslategray|darkslategrey|darkturquoise|darkviolet|deeppink|deepskyblue|dimgray|dimgrey|dodgerblue|firebrick|floralwhite|forestgreen|gainsboro|ghostwhite|gold|goldenrod|greenyellow|grey|honeydew|hotpink|indianred|indigo|ivory|khaki|lavender|lavenderblush|lawngreen|lemonchiffon|lightblue|lightcoral|lightcyan|lightgoldenrodyellow|lightgray|lightgreen|lightgrey|lightpink|lightsalmon|lightseagreen|lightskyblue|lightslategray|lightslategrey|lightsteelblue|lightyellow|limegreen|linen|magenta|mediumaquamarine|mediumblue|mediumorchid|mediumpurple|mediumseagreen|mediumslateblue|mediumspringgreen|mediumturquoise|mediumvioletred|midnightblue|mintcream|mistyrose|moccasin|navajowhite|oldlace|olivedrab|orangered|orchid|palegoldenrod|palegreen|paleturquoise|palevioletred|papayawhip|peachpuff|peru|pink|plum|powderblue|rebeccapurple|rosybrown|royalblue|saddlebrown|salmon|sandybrown|seagreen|seashell|sienna|skyblue|slateblue|slategray|slategrey|snow|springgreen|steelblue|tan|thistle|tomato|transparent|turquoise|violet|wheat|whitesmoke|yellowgreen)$"))
|
||||
|
||||
((plain_value) @invalid.deprecated.color.system.scss
|
||||
(#match? @invalid.deprecated.color.system.scss "^(ActiveBorder|ActiveCaption|AppWorkspace|Background|ButtonFace|ButtonHighlight|ButtonShadow|ButtonText|CaptionText|GrayText|Highlight|HighlightText|InactiveBorder|InactiveCaption|InactiveCaptionText|InfoBackground|InfoText|Menu|MenuText|Scrollbar|ThreeDDarkShadow|ThreeDFace|ThreeDHighlight|ThreeDLightShadow|ThreeDShadow|Window|WindowFrame|WindowText)$"))
|
||||
|
||||
; Builtins
|
||||
; --------
|
||||
|
||||
(boolean_value) @constant.boolean._TEXT_.scss
|
||||
(null_value) @constant.language.null.scss
|
||||
|
||||
|
||||
; FUNCTIONS
|
||||
; =========
|
||||
|
||||
((function_name) @support.function.var.css.scss
|
||||
(arguments (plain_value) @variable.css.scss)
|
||||
(#eq? @support.function.var.css.scss "var")
|
||||
(#set! capture.final true))
|
||||
|
||||
((function_name) @support.function._TEXT_.css.scss
|
||||
(#match? @support.function._TEXT_.css.scss "^(abs|acos|annotation|asin|atan2?attr|blur|brightness|calc|character-variant|circle|clamp|color-contrast|color-mix|conic-gradient|contrast|cos|counters|cross-fade|cubic-bezier|device-cmyk|drop-shadow|element|ellipse|env|exp|format|grayscale|hsla?|hue-rotate|hwp|hypot|image|image-set|inset|invert|lab|lch|linear-gradient|local|log|matrix|matrix3d|max|min|minmax|mod|oklab|oklch|opacity|ornaments|paint|path|perspective|polygon|pow|radial-gradient|ray|rect|rem|repeat|repeating-(conic|linear|radial)-gradient|rgba?|rotate(3d)?|rotate(X|Y|Z)|round|saturate|scale(3d)?|scale(X|Y|Z)|sepia|sign|sin|skew(X|Y)?|sqrt|steps|styleset|stylistic|swash|symbols|tan|translate(3d)?|translate(X|Y|Z)|url)$")
|
||||
(#set! capture.final true))
|
||||
|
||||
((function_name) @support.other.function._TEXT_.scss)
|
||||
|
||||
((function_name) @_IGNORE_
|
||||
(arguments (plain_value) @string.unquoted.scss)
|
||||
(#eq? @_IGNORE_ "url"))
|
||||
|
||||
((module) @support.module._TEXT_.scss
|
||||
(#match? @support.module._TEXT_.scss "^(color|list|map|math|meta|selector|string)$")
|
||||
(#set! capture.final true))
|
||||
|
||||
(module) @support.other.module.scss
|
||||
|
||||
|
||||
; MIXINS
|
||||
; ======
|
||||
|
||||
(mixin_statement
|
||||
(name) @entity.name.function.mixin.scss)
|
||||
|
||||
(include_statement
|
||||
(mixin_name) @support.other.function.mixin.scss)
|
||||
|
||||
|
||||
; AT-RULES
|
||||
; ========
|
||||
|
||||
"@media" @keyword.control.at-rule.media.css.scss
|
||||
"@import" @keyword.control.at-rule.import.css.scss
|
||||
"@charset" @keyword.control.at-rule.charset.css.scss
|
||||
"@namespace" @keyword.control.at-rule.namespace.css.scss
|
||||
"@supports" @keyword.control.at-rule.supports.css.scss
|
||||
"@keyframes" @keyword.control.at-rule.keyframes.css.scss
|
||||
|
||||
"@include" @keyword.control.at-rule.include.scss
|
||||
"@mixin" @keyword.control.at-rule.mixin.scss
|
||||
"@if" @keyword.control.at-rule.if.scss
|
||||
"@else" @keyword.control.at-rule.else.scss
|
||||
"@for" @keyword.control.at-rule.for.scss
|
||||
"@use" @keyword.control.at-rule.use.scss
|
||||
"@forward" @keyword.control.at-rule.forward.scss
|
||||
"@extend" @keyword.control.at-rule.extend.scss
|
||||
"@function" @keyword.control.at-rule.function.scss
|
||||
"@return" @keyword.control.at-rule.return.scss
|
||||
"@each" @keyword.control.at-rule.each.scss
|
||||
"@at-root" @keyword.control.at-rule.at-root.scss
|
||||
|
||||
"@error" @keyword.directive.error.scss
|
||||
"@warn" @keyword.directive.warn.scss
|
||||
"@debug" @keyword.directive.debug.scss
|
||||
|
||||
(each_statement "in" @keyword.control.in.scss)
|
||||
|
||||
; The parser is permissive and supports at-rule keywords that don't currently
|
||||
; exist, so we'll set a fallback scope for those.
|
||||
((at_keyword) @keyword.control.at-rule.other.scss
|
||||
(#set! capture.shy true))
|
||||
|
||||
[(to) (from)] @keyword.control._TYPE_.css.scss
|
||||
|
||||
(keyword_query) @support.constant.css.scss
|
||||
(feature_name) @support.constant.css.scss
|
||||
|
||||
[
|
||||
"as"
|
||||
"from"
|
||||
"through"
|
||||
] @keyword.control._TYPE_.scss
|
||||
|
||||
|
||||
(id_selector
|
||||
"#" @punctuation.definition.entity.id.scss) @entity.other.attribute-name.id.scss
|
||||
|
||||
((use_alias) @variable.language.alias.expanded.scss
|
||||
(#eq? @variable.language.alias.expanded.scss "*")
|
||||
(#set! capture.final true))
|
||||
|
||||
(use_alias) @variable.other.alias.scss
|
||||
|
||||
; FUNCTIONS
|
||||
; =========
|
||||
|
||||
(function_statement (name) @entity.name.function.scss)
|
||||
|
||||
|
||||
; OPERATORS
|
||||
; =========
|
||||
|
||||
; Used in `@media` queries.
|
||||
["and" "not" "only" "or"] @keyword.operator.logical._TYPE_.scss
|
||||
|
||||
; Used in `calc()` and elsewhere.
|
||||
(binary_expression ["+" "-" "*" "/"] @keyword.operator.arithmetic.scss)
|
||||
|
||||
"..." @keyword.operator.spread.scss
|
||||
|
||||
; When `ERROR` is present here, it's typically because a rest parameter or
|
||||
; argument is not the last in the list. Indicate this to the user by marking
|
||||
; the '...' itself as invalid.
|
||||
(ERROR
|
||||
[
|
||||
(rest_parameter "..." @invalid.illegal.spread.scss)
|
||||
(rest_argument "..." @invalid.illegal.spread.scss)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
; INTERPOLATION
|
||||
; =============
|
||||
|
||||
(interpolation) @meta.embedded.line.interpolation.scss
|
||||
(interpolation "#{" @punctuation.section.embedded.begin.scss)
|
||||
(interpolation "}" @punctuation.section.embedded.end.scss)
|
||||
|
||||
; OTHER STUFF
|
||||
; ===========
|
||||
|
||||
(keyframes_statement
|
||||
name: (keyframes_name) @entity.name.keyframes.css.scss)
|
||||
|
||||
(nesting_value) @entity.other.tag.reference.scss
|
||||
|
||||
; PUNCTUATION
|
||||
; ===========
|
||||
|
||||
(parameters "(") @punctuation.definition.parameters.begin.brace.round.scss
|
||||
(parameters ")") @punctuation.definition.parameters.end.brace.round.scss
|
||||
|
||||
"," @punctuation.separator.comma.scss
|
||||
":" @punctuation.separator.colon.scss
|
||||
";" @punctuation.separator.semicolon.scss
|
||||
|
||||
("{" @punctuation.brace.curly.begin.scss
|
||||
(#set! capture.shy))
|
||||
("}" @punctuation.brace.curly.end.scss
|
||||
(#set! capture.shy))
|
||||
|
||||
("(" @punctuation.brace.round.begin.scss
|
||||
(#set! capture.shy))
|
||||
(")" @punctuation.brace.round.end.scss
|
||||
(#set! capture.shy))
|
||||
|
||||
(":" @punctuation.separator.key-value.scss
|
||||
(#set! capture.shy))
|
||||
|
||||
|
||||
; SECTIONS
|
||||
; ========
|
||||
|
||||
(rule_set (block) @meta.block.inside-selector.scss)
|
||||
((block) @meta.block.scss
|
||||
(#set! capture.shy))
|
||||
(selectors) @meta.selector.scss
|
3
packages/language-sass/grammars/tree-sitter/indents.scm
Normal file
3
packages/language-sass/grammars/tree-sitter/indents.scm
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
"{" @indent
|
||||
"}" @dedent
|
7
packages/language-sass/grammars/tree-sitter/tags.scm
Normal file
7
packages/language-sass/grammars/tree-sitter/tags.scm
Normal file
@ -0,0 +1,7 @@
|
||||
(rule_set (selectors) @name) @definition.selector
|
||||
|
||||
(keyframes_statement (keyframes_name) @name) @definition.keyframes
|
||||
|
||||
(mixin_statement (name) @name) @definition.mixin
|
||||
|
||||
(function_statement (name) @name) @definition.function
|
BIN
packages/language-sass/grammars/tree-sitter/tree-sitter-scss.wasm
Executable file
BIN
packages/language-sass/grammars/tree-sitter/tree-sitter-scss.wasm
Executable file
Binary file not shown.
12
packages/language-sass/lib/main.js
Normal file
12
packages/language-sass/lib/main.js
Normal file
@ -0,0 +1,12 @@
|
||||
|
||||
exports.consumeHyperlinkInjection = (hyperlink) => {
|
||||
hyperlink.addInjectionPoint('source.css.scss', {
|
||||
types: ['comment', 'single_line_comment']
|
||||
});
|
||||
};
|
||||
|
||||
exports.consumeTodoInjection = (todo) => {
|
||||
todo.addInjectionPoint('source.css.scss', {
|
||||
types: ['comment', 'single_line_comment']
|
||||
});
|
||||
};
|
@ -1,14 +1,27 @@
|
||||
{
|
||||
"name": "language-sass",
|
||||
"version": "0.62.2",
|
||||
"main": "lib/main",
|
||||
"description": "Sass/SCSS language support in Atom",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"atom": "*",
|
||||
"node": "*"
|
||||
"node": ">=12"
|
||||
},
|
||||
"repository": "https://github.com/pulsar-edit/pulsar",
|
||||
"devDependencies": {
|
||||
"dedent": "^0.7.0"
|
||||
},
|
||||
"consumedServices": {
|
||||
"hyperlink.injection": {
|
||||
"versions": {
|
||||
"0.1.0": "consumeHyperlinkInjection"
|
||||
}
|
||||
},
|
||||
"todo.injection": {
|
||||
"versions": {
|
||||
"0.1.0": "consumeTodoInjection"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
49
packages/language-sass/snippets/scss.cson
Normal file
49
packages/language-sass/snippets/scss.cson
Normal file
@ -0,0 +1,49 @@
|
||||
'.source.css.scss':
|
||||
'!important':
|
||||
prefix: '!'
|
||||
body: 'i!important${:;}$0'
|
||||
'@use':
|
||||
prefix: 'use'
|
||||
body: "@use '${1:file}'${2: as ${3:alias}};"
|
||||
'@import':
|
||||
prefix: 'import'
|
||||
body: '@import "$0";'
|
||||
description: "An enhanced version of CSS’s “@import” rule."
|
||||
descriptionMoreURL: "https://sass-lang.com/documentation/at-rules/import/"
|
||||
'@include':
|
||||
prefix: 'include'
|
||||
body: '@include ${1:mixin}${2:($3)};$0'
|
||||
description: "Include a mixin into the current context."
|
||||
descriptionMoreURL: "https://sass-lang.com/documentation/at-rules/mixin/"
|
||||
'@extend':
|
||||
prefix: 'extend'
|
||||
body: '@extend ${1}$0';
|
||||
description: "Tells one selector to inherit the styles of another."
|
||||
descriptionMoreURL: "https://sass-lang.com/documentation/at-rules/extend/"
|
||||
'@if':
|
||||
prefix: 'if'
|
||||
body: """
|
||||
@if ${1:conditions} {
|
||||
$0
|
||||
}
|
||||
"""
|
||||
description: "Controls whether or not its block gets evaluated."
|
||||
descriptionMoreURL: "https://sass-lang.com/documentation/at-rules/control/if/"
|
||||
'@mixin':
|
||||
prefix: 'mixin'
|
||||
body: """
|
||||
@mixin ${1:name}${2:($3)} {
|
||||
$0
|
||||
}
|
||||
"""
|
||||
description: "Include a mixin."
|
||||
descriptionMoreURL: "https://sass-lang.com/documentation/at-rules/mixin/"
|
||||
'@function':
|
||||
prefix: 'fun'
|
||||
body: """
|
||||
@mixin ${1:name} {
|
||||
$0
|
||||
}
|
||||
"""
|
||||
description: "Define your own function."
|
||||
descriptionMoreURL: "https://sass-lang.com/documentation/at-rules/function/"
|
@ -1,15 +1,19 @@
|
||||
|
||||
describe('SassDoc grammar', function() {
|
||||
describe('SassDoc grammar', function () {
|
||||
let grammar = null;
|
||||
|
||||
beforeEach(function() {
|
||||
beforeEach(function () {
|
||||
// There isn't a Tree-sitter grammar for SassDoc that I'm aware of. Users
|
||||
// who expect thorough highlighting of SassDoc can add a scope-specific
|
||||
// override to prefer the TextMate-style SCSS grammar.
|
||||
atom.config.set('core.useTreeSitterParsers', false)
|
||||
waitsForPromise(() => atom.packages.activatePackage('language-sass'));
|
||||
|
||||
runs(() => grammar = atom.grammars.grammarForScopeName('source.css.scss'));
|
||||
});
|
||||
|
||||
describe('block tags', function() {
|
||||
it('tokenises simple tags', function() {
|
||||
describe('block tags', function () {
|
||||
it('tokenises simple tags', function () {
|
||||
const {tokens} = grammar.tokenizeLine('/// @deprecated');
|
||||
expect(tokens[0]).toEqual({value: '///', scopes: ['source.css.scss', 'comment.block.documentation.scss', 'punctuation.definition.comment.scss']});
|
||||
expect(tokens[1]).toEqual({value: ' ', scopes: ['source.css.scss', 'comment.block.documentation.scss']});
|
||||
@ -17,7 +21,7 @@ describe('SassDoc grammar', function() {
|
||||
expect(tokens[3]).toEqual({value: 'deprecated', scopes: ['source.css.scss', 'comment.block.documentation.scss', 'storage.type.class.sassdoc']});
|
||||
});
|
||||
|
||||
it('tokenises @param tags with a description', function() {
|
||||
it('tokenises @param tags with a description', function () {
|
||||
const {tokens} = grammar.tokenizeLine('/// @param {type} $name - Description');
|
||||
expect(tokens[0]).toEqual({value: '///', scopes: ['source.css.scss', 'comment.block.documentation.scss', 'punctuation.definition.comment.scss']});
|
||||
expect(tokens[2]).toEqual({value: '@', scopes: ['source.css.scss', 'comment.block.documentation.scss', 'storage.type.class.sassdoc', 'punctuation.definition.block.tag.sassdoc']});
|
||||
@ -30,7 +34,7 @@ describe('SassDoc grammar', function() {
|
||||
});
|
||||
});
|
||||
|
||||
describe('highlighted examples', () => it('highlights SCSS after an @example tag', function() {
|
||||
describe('highlighted examples', () => it('highlights SCSS after an @example tag', function () {
|
||||
const lines = grammar.tokenizeLines(`\
|
||||
///
|
||||
/// @example scss - Description
|
||||
|
@ -18,16 +18,16 @@
|
||||
(identifier) @variable.other.assignment.import.namespace._LANG_)
|
||||
|
||||
; The "*" in `export * from 'bar'`
|
||||
(export_statement "*" @variable.other.assignment.export.all.js)
|
||||
(export_statement "*" @variable.other.assignment.export.all._LANG_)
|
||||
|
||||
; The "*" in `export * as Foo from 'bar'`
|
||||
(export_statement
|
||||
(namespace_export "*" @variable.other.assignment.export.all.js))
|
||||
(namespace_export "*" @variable.other.assignment.export.all._LANG_))
|
||||
|
||||
; The "*" in `export * as Foo from 'bar'`
|
||||
(export_statement
|
||||
(namespace_export
|
||||
(identifier) @variable.other.assignment.export.alias.js))
|
||||
(identifier) @variable.other.assignment.export.alias._LANG_))
|
||||
|
||||
; The "Foo" in `export { Foo }`
|
||||
(export_specifier
|
||||
@ -51,7 +51,7 @@
|
||||
; =========
|
||||
|
||||
(this) @variable.language.this._LANG_
|
||||
(super) @variable.language.super._LANG_._LANG_x
|
||||
(super) @variable.language.super._LANG_
|
||||
|
||||
(required_parameter
|
||||
pattern: (identifier) @variable.parameter.with-default._LANG_
|
||||
@ -345,7 +345,7 @@
|
||||
|
||||
"=>" @storage.type.arrow._LANG_
|
||||
|
||||
; TODO: If I allow scopes like `storage.type.string._LANG_`, I will make a lot of
|
||||
; TODO: If I allow scopes like `storage.type.string.ts`, I will make a lot of
|
||||
; text look like strings by accident. This really needs to be fixed in syntax
|
||||
; themes.
|
||||
;
|
||||
@ -764,10 +764,10 @@
|
||||
) @meta.embedded.line.interpolation._LANG_
|
||||
|
||||
(string
|
||||
(escape_sequence) @constant.character.escape.js)
|
||||
(escape_sequence) @constant.character.escape._LANG_)
|
||||
|
||||
(template_string
|
||||
(escape_sequence) @constant.character.escape.js)
|
||||
(escape_sequence) @constant.character.escape._LANG_)
|
||||
|
||||
|
||||
; CONSTANTS
|
||||
@ -817,16 +817,16 @@
|
||||
; REGEX
|
||||
; =====
|
||||
|
||||
(regex) @string.regexp.js
|
||||
(regex) @string.regexp._LANG_
|
||||
(regex
|
||||
"/" @punctuation.definition.string.begin.js
|
||||
"/" @punctuation.definition.string.begin._LANG_
|
||||
(#is? test.first))
|
||||
|
||||
(regex
|
||||
"/" @punctuation.definition.string.end.js
|
||||
"/" @punctuation.definition.string.end._LANG_
|
||||
(#is? test.last))
|
||||
|
||||
(regex_flags) @keyword.other.js
|
||||
(regex_flags) @keyword.other._LANG_
|
||||
|
||||
|
||||
; OPERATORS
|
||||
@ -837,26 +837,28 @@
|
||||
|
||||
"=" @keyword.operator.assignment._LANG_
|
||||
(non_null_expression "!" @keyword.operator.non-null._LANG_)
|
||||
(unary_expression"!" @keyword.operator.unary._LANG_)
|
||||
(unary_expression "!" @keyword.operator.unary._LANG_)
|
||||
|
||||
[
|
||||
"&&="
|
||||
"||="
|
||||
"??="
|
||||
"+="
|
||||
"-="
|
||||
"*="
|
||||
"**="
|
||||
"/="
|
||||
"%="
|
||||
"^="
|
||||
"&="
|
||||
"|="
|
||||
"<<="
|
||||
">>="
|
||||
">>>="
|
||||
"&="
|
||||
"^="
|
||||
"|="
|
||||
"??="
|
||||
"||="
|
||||
] @keyword.operator.assignment.compound._LANG_
|
||||
|
||||
(binary_expression
|
||||
["+" "-" "*" "/" "%"] @keyword.operator.arithmetic._LANG_)
|
||||
["/" "+" "-" "*" "**" "%"] @keyword.operator.arithmetic._LANG_)
|
||||
|
||||
(unary_expression ["+" "-"] @keyword.operator.unary._LANG_)
|
||||
|
||||
@ -866,15 +868,14 @@
|
||||
"==="
|
||||
"!="
|
||||
"!=="
|
||||
">="
|
||||
"<="
|
||||
">"
|
||||
"<"
|
||||
] @keyword.operator.comparison._LANG_
|
||||
)
|
||||
|
||||
["++" "--"] @keyword.operator.increment._LANG_
|
||||
|
||||
(binary_expression
|
||||
[">=" "<=" ">" "<"] @keyword.operator.relational._LANG_)
|
||||
|
||||
[
|
||||
"&&"
|
||||
"||"
|
||||
@ -902,6 +903,20 @@
|
||||
"." @keyword.operator.accessor._LANG_
|
||||
"?." @keyword.operator.accessor.optional-chaining._LANG_
|
||||
|
||||
; Optional chaining is illegal…
|
||||
|
||||
; …on the left-hand side of an assignment.
|
||||
(assignment_expression
|
||||
left: (_) @_IGNORE_
|
||||
(#set! prohibitsOptionalChaining true))
|
||||
|
||||
; …within a `new` expression.
|
||||
(new_expression
|
||||
constructor: (_) @_IGNORE_
|
||||
(#set! prohibitsOptionalChaining true))
|
||||
|
||||
((optional_chain) @invalid.illegal.optional-chain._LANG_
|
||||
(#is? test.descendantOfNodeWithData prohibitsOptionalChaining))
|
||||
|
||||
(ternary_expression
|
||||
["?" ":"] @keyword.operator.ternary._LANG_
|
||||
|
@ -12,10 +12,18 @@ const isMarkdownPreviewView = function (object) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
activate () {
|
||||
activate() {
|
||||
this.disposables = new CompositeDisposable()
|
||||
this.commandSubscriptions = new CompositeDisposable()
|
||||
|
||||
this.style = new CSSStyleSheet()
|
||||
|
||||
// TODO: When we upgrade Electron, we can push onto `adoptedStyleSheets`
|
||||
// directly. For now, we have to do this silly thing.
|
||||
let styleSheets = Array.from(document.adoptedStyleSheets ?? [])
|
||||
styleSheets.push(this.style)
|
||||
document.adoptedStyleSheets = styleSheets
|
||||
|
||||
this.disposables.add(
|
||||
atom.config.observe('markdown-preview.grammars', grammars => {
|
||||
this.commandSubscriptions.dispose()
|
||||
@ -53,6 +61,22 @@ module.exports = {
|
||||
})
|
||||
)
|
||||
|
||||
this.disposables.add(
|
||||
atom.config.observe('editor.fontFamily', (fontFamily) => {
|
||||
// Keep the user's `fontFamily` setting in sync with preview styles.
|
||||
// `pre` blocks will use this font automatically, but `code` elements
|
||||
// need a specific style rule.
|
||||
//
|
||||
// Since this applies to all content, we should declare this only once,
|
||||
// instead of once per preview view.
|
||||
this.style.replaceSync(`
|
||||
.markdown-preview code {
|
||||
font-family: ${fontFamily} !important;
|
||||
}
|
||||
`)
|
||||
})
|
||||
)
|
||||
|
||||
const previewFile = this.previewFile.bind(this)
|
||||
for (const extension of [
|
||||
'markdown',
|
||||
@ -94,12 +118,12 @@ module.exports = {
|
||||
)
|
||||
},
|
||||
|
||||
deactivate () {
|
||||
deactivate() {
|
||||
this.disposables.dispose()
|
||||
this.commandSubscriptions.dispose()
|
||||
},
|
||||
|
||||
createMarkdownPreviewView (state) {
|
||||
createMarkdownPreviewView(state) {
|
||||
if (state.editorId || fs.isFileSync(state.filePath)) {
|
||||
if (MarkdownPreviewView == null) {
|
||||
MarkdownPreviewView = require('./markdown-preview-view')
|
||||
@ -108,7 +132,7 @@ module.exports = {
|
||||
}
|
||||
},
|
||||
|
||||
toggle () {
|
||||
toggle() {
|
||||
if (isMarkdownPreviewView(atom.workspace.getActivePaneItem())) {
|
||||
atom.workspace.destroyActivePaneItem()
|
||||
return
|
||||
@ -129,11 +153,11 @@ module.exports = {
|
||||
}
|
||||
},
|
||||
|
||||
uriForEditor (editor) {
|
||||
uriForEditor(editor) {
|
||||
return `markdown-preview://editor/${editor.id}`
|
||||
},
|
||||
|
||||
removePreviewForEditor (editor) {
|
||||
removePreviewForEditor(editor) {
|
||||
const uri = this.uriForEditor(editor)
|
||||
const previewPane = atom.workspace.paneForURI(uri)
|
||||
if (previewPane != null) {
|
||||
@ -144,7 +168,7 @@ module.exports = {
|
||||
}
|
||||
},
|
||||
|
||||
addPreviewForEditor (editor) {
|
||||
addPreviewForEditor(editor) {
|
||||
const uri = this.uriForEditor(editor)
|
||||
const previousActivePane = atom.workspace.getActivePane()
|
||||
const options = { searchAllPanes: true }
|
||||
@ -161,7 +185,7 @@ module.exports = {
|
||||
})
|
||||
},
|
||||
|
||||
previewFile ({ target }) {
|
||||
previewFile({ target }) {
|
||||
const filePath = target.dataset.path
|
||||
if (!filePath) {
|
||||
return
|
||||
@ -178,7 +202,7 @@ module.exports = {
|
||||
})
|
||||
},
|
||||
|
||||
async copyHTML () {
|
||||
async copyHTML() {
|
||||
const editor = atom.workspace.getActiveTextEditor()
|
||||
if (editor == null) {
|
||||
return
|
||||
@ -191,13 +215,14 @@ module.exports = {
|
||||
const html = await renderer.toHTML(
|
||||
text,
|
||||
editor.getPath(),
|
||||
editor.getGrammar()
|
||||
editor.getGrammar(),
|
||||
editor.id
|
||||
)
|
||||
|
||||
atom.clipboard.write(html)
|
||||
},
|
||||
|
||||
saveAsHTML () {
|
||||
saveAsHTML() {
|
||||
const activePaneItem = atom.workspace.getActivePaneItem()
|
||||
if (isMarkdownPreviewView(activePaneItem)) {
|
||||
atom.workspace.getActivePane().saveItemAs(activePaneItem)
|
||||
|
@ -1,4 +1,5 @@
|
||||
const path = require('path')
|
||||
const morphdom = require('morphdom')
|
||||
|
||||
const { Emitter, Disposable, CompositeDisposable, File } = require('atom')
|
||||
const _ = require('underscore-plus')
|
||||
@ -17,6 +18,7 @@ module.exports = class MarkdownPreviewView {
|
||||
this.element = document.createElement('div')
|
||||
this.element.classList.add('markdown-preview')
|
||||
this.element.tabIndex = -1
|
||||
|
||||
this.emitter = new Emitter()
|
||||
this.loaded = false
|
||||
this.disposables = new CompositeDisposable()
|
||||
@ -32,6 +34,7 @@ module.exports = class MarkdownPreviewView {
|
||||
})
|
||||
)
|
||||
}
|
||||
this.editorCache = new renderer.EditorCache(editorId)
|
||||
}
|
||||
|
||||
serialize() {
|
||||
@ -52,6 +55,7 @@ module.exports = class MarkdownPreviewView {
|
||||
destroy() {
|
||||
this.disposables.dispose()
|
||||
this.element.remove()
|
||||
this.editorCache.destroy()
|
||||
}
|
||||
|
||||
registerScrollCommands() {
|
||||
@ -83,7 +87,7 @@ module.exports = class MarkdownPreviewView {
|
||||
return this.emitter.on('did-change-title', callback)
|
||||
}
|
||||
|
||||
onDidChangeModified(callback) {
|
||||
onDidChangeModified(_callback) {
|
||||
// No op to suppress deprecation warning
|
||||
return new Disposable()
|
||||
}
|
||||
@ -270,7 +274,22 @@ module.exports = class MarkdownPreviewView {
|
||||
return this.getMarkdownSource()
|
||||
.then(source => {
|
||||
if (source != null) {
|
||||
return this.renderMarkdownText(source)
|
||||
if (this.loaded) {
|
||||
return this.renderMarkdownText(source);
|
||||
} else {
|
||||
// If we haven't loaded yet, defer before we render the Markdown
|
||||
// for the first time. This allows the pane to appear and to
|
||||
// display the loading indicator. Otherwise the first render
|
||||
// happens before the pane is even visible.
|
||||
//
|
||||
// This doesn't slow anything down; it just shifts the work around
|
||||
// so that the pane appears earlier in the cycle.
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(this.renderMarkdownText(source))
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(reason => this.showError({ message: reason }))
|
||||
@ -309,18 +328,34 @@ module.exports = class MarkdownPreviewView {
|
||||
|
||||
async renderMarkdownText(text) {
|
||||
const { scrollTop } = this.element
|
||||
|
||||
try {
|
||||
const domFragment = await renderer.toDOMFragment(
|
||||
const [domFragment, done] = await renderer.toDOMFragment(
|
||||
text,
|
||||
this.getPath(),
|
||||
this.getGrammar()
|
||||
this.getGrammar(),
|
||||
this.editorId
|
||||
)
|
||||
|
||||
this.loading = false
|
||||
this.loaded = true
|
||||
this.element.textContent = ''
|
||||
this.element.appendChild(domFragment)
|
||||
|
||||
// Clone the existing container
|
||||
let newElement = this.element.cloneNode(false)
|
||||
newElement.appendChild(domFragment)
|
||||
|
||||
morphdom(this.element, newElement, {
|
||||
onBeforeNodeDiscarded(node) {
|
||||
// Don't discard `atom-text-editor` elements despite the fact that
|
||||
// they don't exist in the new content.
|
||||
if (node.nodeName === 'ATOM-TEXT-EDITOR') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await done(this.element)
|
||||
this.element.classList.remove('loading')
|
||||
|
||||
this.emitter.emit('did-change-markdown')
|
||||
this.element.scrollTop = scrollTop
|
||||
} catch (error) {
|
||||
@ -400,7 +435,7 @@ module.exports = class MarkdownPreviewView {
|
||||
.join('\n')
|
||||
.replace(/atom-text-editor/g, 'pre.editor-colors')
|
||||
.replace(/:host/g, '.host') // Remove shadow-dom :host selector causing problem on FF
|
||||
.replace(cssUrlRegExp, function (match, assetsName, offset, string) {
|
||||
.replace(cssUrlRegExp, function (_match, assetsName, _offset, _string) {
|
||||
// base64 encode assets
|
||||
const assetPath = path.join(__dirname, '../assets', assetsName)
|
||||
const originalData = fs.readFileSync(assetPath, 'binary')
|
||||
@ -413,6 +448,7 @@ module.exports = class MarkdownPreviewView {
|
||||
|
||||
showError(result) {
|
||||
this.element.textContent = ''
|
||||
this.element.classList.remove('loading')
|
||||
const h2 = document.createElement('h2')
|
||||
h2.textContent = 'Previewing Markdown Failed'
|
||||
this.element.appendChild(h2)
|
||||
@ -425,11 +461,7 @@ module.exports = class MarkdownPreviewView {
|
||||
|
||||
showLoading() {
|
||||
this.loading = true
|
||||
this.element.textContent = ''
|
||||
const div = document.createElement('div')
|
||||
div.classList.add('markdown-spinner')
|
||||
div.textContent = 'Loading Markdown\u2026'
|
||||
this.element.appendChild(div)
|
||||
this.element.classList.add('loading')
|
||||
}
|
||||
|
||||
selectAll() {
|
||||
|
@ -17,91 +17,147 @@ const emojiFolder = path.join(
|
||||
'pngs'
|
||||
)
|
||||
|
||||
exports.toDOMFragment = async function (text, filePath, grammar, callback) {
|
||||
// Creating `TextEditor` instances is costly, so we'll try to re-use instances
|
||||
// when a preview changes.
|
||||
class EditorCache {
|
||||
static BY_ID = new Map()
|
||||
|
||||
text ??= "";
|
||||
|
||||
if (atom.config.get("markdown-preview.useOriginalParser")) {
|
||||
const domFragment = render(text, filePath);
|
||||
|
||||
await highlightCodeBlocks(domFragment, grammar, makeAtomEditorNonInteractive);
|
||||
|
||||
return domFragment;
|
||||
|
||||
} else {
|
||||
// We use the new parser!
|
||||
const domFragment = atom.ui.markdown.render(text,
|
||||
{
|
||||
renderMode: "fragment",
|
||||
filePath: filePath,
|
||||
breaks: atom.config.get('markdown-preview.breakOnSingleNewline'),
|
||||
useDefaultEmoji: true,
|
||||
sanitizeAllowUnknownProtocols: atom.config.get('markdown-preview.allowUnsafeProtocols')
|
||||
}
|
||||
);
|
||||
const domHTMLFragment = atom.ui.markdown.convertToDOM(domFragment);
|
||||
await atom.ui.markdown.applySyntaxHighlighting(domHTMLFragment,
|
||||
{
|
||||
renderMode: "fragment",
|
||||
syntaxScopeNameFunc: scopeForFenceName,
|
||||
grammar: grammar
|
||||
}
|
||||
);
|
||||
|
||||
return domHTMLFragment;
|
||||
static findOrCreateById(id) {
|
||||
let cache = EditorCache.BY_ID.get(id)
|
||||
if (!cache) {
|
||||
cache = new EditorCache(id)
|
||||
EditorCache.BY_ID.set(id, cache)
|
||||
}
|
||||
return cache
|
||||
}
|
||||
|
||||
constructor(id) {
|
||||
this.id = id
|
||||
this.editorsByPre = new Map()
|
||||
this.possiblyUnusedEditors = new Set()
|
||||
}
|
||||
|
||||
destroy() {
|
||||
let editors = Array.from(this.editorsByPre.values())
|
||||
for (let editor of editors) {
|
||||
editor.destroy()
|
||||
}
|
||||
this.editorsByPre.clear()
|
||||
this.possiblyUnusedEditors.clear()
|
||||
EditorCache.BY_ID.delete(this.id)
|
||||
}
|
||||
|
||||
// Called when we start a render. Every `TextEditor` is assumed to be stale,
|
||||
// but any editor that is successfully looked up from the cache during this
|
||||
// render is saved from culling.
|
||||
beginRender() {
|
||||
this.possiblyUnusedEditors.clear()
|
||||
for (let editor of this.editorsByPre.values()) {
|
||||
this.possiblyUnusedEditors.add(editor)
|
||||
}
|
||||
}
|
||||
|
||||
// Cache an editor by the PRE element that it's standing in for.
|
||||
addEditor(pre, editor) {
|
||||
this.editorsByPre.set(pre, editor)
|
||||
}
|
||||
|
||||
getEditor(pre) {
|
||||
let editor = this.editorsByPre.get(pre)
|
||||
if (editor) {
|
||||
// Cache hit! This editor will be reused, so we should prevent it from
|
||||
// getting culled.
|
||||
this.possiblyUnusedEditors.delete(editor)
|
||||
}
|
||||
return editor
|
||||
}
|
||||
|
||||
endRender() {
|
||||
// Any editor that didn't get claimed during the render is orphaned and
|
||||
// should be disposed of.
|
||||
let toBeDeleted = new Set()
|
||||
for (let [pre, editor] of this.editorsByPre.entries()) {
|
||||
if (!this.possiblyUnusedEditors.has(editor)) continue
|
||||
toBeDeleted.add(pre)
|
||||
}
|
||||
|
||||
this.possiblyUnusedEditors.clear()
|
||||
|
||||
for (let pre of toBeDeleted) {
|
||||
let editor = this.editorsByPre.get(pre)
|
||||
let element = editor.getElement()
|
||||
if (element.parentNode) {
|
||||
element.remove()
|
||||
}
|
||||
this.editorsByPre.delete(pre)
|
||||
editor.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exports.EditorCache = EditorCache
|
||||
|
||||
function chooseRender(text, filePath) {
|
||||
if (atom.config.get("markdown-preview.useOriginalParser")) {
|
||||
// Legacy rendering with `marked`.
|
||||
return render(text, filePath)
|
||||
} else {
|
||||
// Built-in rendering with `markdown-it`.
|
||||
let html = atom.ui.markdown.render(text, {
|
||||
renderMode: "fragment",
|
||||
filePath: filePath,
|
||||
breaks: atom.config.get('markdown-preview.breakOnSingleNewline'),
|
||||
useDefaultEmoji: true,
|
||||
sanitizeAllowUnknownProtocols: atom.config.get('markdown-preview.allowUnsafeProtocols')
|
||||
})
|
||||
return atom.ui.markdown.convertToDOM(html)
|
||||
}
|
||||
}
|
||||
|
||||
exports.toDOMFragment = async function (text, filePath, grammar, editorId) {
|
||||
text ??= ""
|
||||
let defaultLanguage = getDefaultLanguageForGrammar(grammar)
|
||||
|
||||
// We cache editor instances in this code path because it's the one used by
|
||||
// the preview pane, so we expect it to be updated quite frequently.
|
||||
let cache = EditorCache.findOrCreateById(editorId)
|
||||
cache.beginRender()
|
||||
|
||||
const domFragment = chooseRender(text, filePath)
|
||||
annotatePreElements(domFragment, defaultLanguage)
|
||||
|
||||
return [
|
||||
domFragment,
|
||||
async (element) => {
|
||||
await highlightCodeBlocks(element, grammar, cache, makeAtomEditorNonInteractive)
|
||||
cache.endRender()
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
exports.toHTML = async function (text, filePath, grammar) {
|
||||
|
||||
text ??= "";
|
||||
|
||||
if (atom.config.get("markdown-preview.useOriginalParser")) {
|
||||
const domFragment = render(text, filePath)
|
||||
const div = document.createElement('div')
|
||||
// We don't cache editor instances in this code path because it's the one
|
||||
// used by the “Copy HTML” command, so this is likely to be a one-off for
|
||||
// which caches won't help.
|
||||
|
||||
div.appendChild(domFragment)
|
||||
document.body.appendChild(div)
|
||||
const domFragment = chooseRender(text, filePath)
|
||||
const div = document.createElement('div')
|
||||
annotatePreElements(domFragment, getDefaultLanguageForGrammar(grammar))
|
||||
div.appendChild(domFragment)
|
||||
document.body.appendChild(div)
|
||||
|
||||
await highlightCodeBlocks(div, grammar, convertAtomEditorToStandardElement)
|
||||
await highlightCodeBlocks(div, grammar, null, convertAtomEditorToStandardElement)
|
||||
|
||||
const result = div.innerHTML
|
||||
div.remove()
|
||||
const result = div.innerHTML;
|
||||
div.remove();
|
||||
|
||||
return result
|
||||
} else {
|
||||
// We use the new parser!
|
||||
const domFragment = atom.ui.markdown.render(text,
|
||||
{
|
||||
renderMode: "full",
|
||||
filePath: filePath,
|
||||
breaks: atom.config.get('markdown-preview.breakOnSingleNewline'),
|
||||
useDefaultEmoji: true,
|
||||
sanitizeAllowUnknownProtocols: atom.config.get('markdown-preview.allowUnsafeProtocols')
|
||||
}
|
||||
);
|
||||
const domHTMLFragment = atom.ui.markdown.convertToDOM(domFragment);
|
||||
|
||||
const div = document.createElement("div");
|
||||
div.appendChild(domHTMLFragment);
|
||||
document.body.appendChild(div);
|
||||
|
||||
await atom.ui.markdown.applySyntaxHighlighting(div,
|
||||
{
|
||||
renderMode: "full",
|
||||
syntaxScopeNameFunc: scopeForFenceName,
|
||||
grammar: grammar
|
||||
}
|
||||
);
|
||||
|
||||
const result = div.innerHTML;
|
||||
div.remove();
|
||||
|
||||
return result;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
var render = function (text, filePath) {
|
||||
// Render with the package's own `marked` library.
|
||||
function render(text, filePath) {
|
||||
if (marked == null || yamlFrontMatter == null || cheerio == null) {
|
||||
marked = require('marked')
|
||||
yamlFrontMatter = require('yaml-front-matter')
|
||||
@ -124,12 +180,13 @@ var render = function (text, filePath) {
|
||||
|
||||
let html = marked.parse(renderYamlTable(vars) + __content)
|
||||
|
||||
// emoji-images is too aggressive, so replace images in monospace tags with the actual emoji text.
|
||||
// emoji-images is too aggressive, so replace images in monospace tags with
|
||||
// the actual emoji text.
|
||||
const $ = cheerio.load(emoji(html, emojiFolder, 20))
|
||||
$('pre img').each((index, element) =>
|
||||
$('pre img').each((_index, element) =>
|
||||
$(element).replaceWith($(element).attr('title'))
|
||||
)
|
||||
$('code img').each((index, element) =>
|
||||
$('code img').each((_index, element) =>
|
||||
$(element).replaceWith($(element).attr('title'))
|
||||
)
|
||||
|
||||
@ -159,7 +216,7 @@ function renderYamlTable(variables) {
|
||||
|
||||
const markdownRows = [
|
||||
entries.map(entry => entry[0]),
|
||||
entries.map(entry => '--'),
|
||||
entries.map(_ => '--'),
|
||||
entries.map((entry) => {
|
||||
if (typeof entry[1] === "object" && !Array.isArray(entry[1])) {
|
||||
// Remove all newlines, or they ruin formatting of parent table
|
||||
@ -175,7 +232,7 @@ function renderYamlTable(variables) {
|
||||
)
|
||||
}
|
||||
|
||||
var resolveImagePaths = function (element, filePath) {
|
||||
function resolveImagePaths(element, filePath) {
|
||||
const [rootDirectory] = atom.project.relativizePath(filePath)
|
||||
|
||||
const result = []
|
||||
@ -219,55 +276,89 @@ var resolveImagePaths = function (element, filePath) {
|
||||
return result
|
||||
}
|
||||
|
||||
var highlightCodeBlocks = function (domFragment, grammar, editorCallback) {
|
||||
let defaultLanguage, fontFamily
|
||||
if (
|
||||
(grammar != null ? grammar.scopeName : undefined) === 'source.litcoffee'
|
||||
) {
|
||||
defaultLanguage = 'coffee'
|
||||
} else {
|
||||
defaultLanguage = 'text'
|
||||
}
|
||||
function getDefaultLanguageForGrammar(grammar) {
|
||||
return grammar?.scopeName === 'source.litcoffee' ? 'coffee' : 'text'
|
||||
}
|
||||
|
||||
if ((fontFamily = atom.config.get('editor.fontFamily'))) {
|
||||
for (const codeElement of domFragment.querySelectorAll('code')) {
|
||||
codeElement.style.fontFamily = fontFamily
|
||||
}
|
||||
function annotatePreElements(fragment, defaultLanguage) {
|
||||
for (let preElement of fragment.querySelectorAll('pre')) {
|
||||
const codeBlock = preElement.firstElementChild ?? preElement
|
||||
const className = codeBlock.getAttribute('class')
|
||||
const fenceName = className?.replace(/^language-/, '') ?? defaultLanguage
|
||||
preElement.classList.add('editor-colors', `lang-${fenceName}`)
|
||||
}
|
||||
}
|
||||
|
||||
function reassignEditorToLanguage(editor, languageScope) {
|
||||
// When we successfully reassign the language on an editor, its
|
||||
// `data-grammar` attribute updates on its own.
|
||||
let result = atom.grammars.assignLanguageMode(editor, languageScope)
|
||||
if (result) return true
|
||||
|
||||
// When we fail to assign the language on an editor — maybe its package is
|
||||
// deactivated — it won't reset itself to the default grammar, so we have to
|
||||
// do it ourselves.
|
||||
result = atom.grammars.assignLanguageMode(editor, `text.plain.null-grammar`)
|
||||
if (!result) return false
|
||||
}
|
||||
|
||||
// After render, create an `atom-text-editor` for each `pre` element so that we
|
||||
// enjoy syntax highlighting.
|
||||
function highlightCodeBlocks(element, grammar, cache, editorCallback) {
|
||||
let defaultLanguage = getDefaultLanguageForGrammar(grammar)
|
||||
|
||||
const promises = []
|
||||
for (const preElement of domFragment.querySelectorAll('pre')) {
|
||||
const codeBlock =
|
||||
preElement.firstElementChild != null
|
||||
? preElement.firstElementChild
|
||||
: preElement
|
||||
|
||||
for (const preElement of element.querySelectorAll('pre')) {
|
||||
const codeBlock = preElement.firstElementChild ?? preElement
|
||||
const className = codeBlock.getAttribute('class')
|
||||
const fenceName =
|
||||
className != null ? className.replace(/^language-/, '') : defaultLanguage
|
||||
const fenceName = className?.replace(/^language-/, '') ?? defaultLanguage
|
||||
let editorText = codeBlock.textContent.replace(/\r?\n$/, '')
|
||||
|
||||
const editor = new TextEditor({
|
||||
readonly: true,
|
||||
keyboardInputEnabled: false
|
||||
})
|
||||
const editorElement = editor.getElement()
|
||||
// If this PRE element was present in the last render, then we should
|
||||
// already have a cached text editor available for use.
|
||||
let editor = cache?.getEditor(preElement) ?? null
|
||||
let editorElement
|
||||
if (!editor) {
|
||||
editor = new TextEditor({ keyboardInputEnabled: false })
|
||||
editorElement = editor.getElement()
|
||||
editor.setReadOnly(true)
|
||||
cache?.addEditor(preElement, editor)
|
||||
} else {
|
||||
editorElement = editor.getElement()
|
||||
}
|
||||
|
||||
preElement.classList.add('editor-colors', `lang-${fenceName}`)
|
||||
editorElement.setUpdatedSynchronously(true)
|
||||
preElement.innerHTML = ''
|
||||
preElement.parentNode.insertBefore(editorElement, preElement)
|
||||
editor.setText(codeBlock.textContent.replace(/\r?\n$/, ''))
|
||||
atom.grammars.assignLanguageMode(editor, scopeForFenceName(fenceName))
|
||||
editor.setVisible(true)
|
||||
// If the PRE changed its content, we need to change the content of its
|
||||
// `TextEditor`.
|
||||
if (editor.getText() !== editorText) {
|
||||
editor.setReadOnly(false)
|
||||
editor.setText(editorText)
|
||||
editor.setReadOnly(true)
|
||||
}
|
||||
|
||||
// If the PRE changed its language, we need to change the language of its
|
||||
// `TextEditor`.
|
||||
let scopeDescriptor = editor.getRootScopeDescriptor()[0]
|
||||
let languageScope = scopeForFenceName(fenceName)
|
||||
if (languageScope !== scopeDescriptor && `.${languageScope}` !== scopeDescriptor) {
|
||||
reassignEditorToLanguage(editor, languageScope)
|
||||
}
|
||||
|
||||
// If the editor is brand new, we'll have to insert it; otherwise it should
|
||||
// already be in the right place.
|
||||
if (!editorElement.parentNode) {
|
||||
preElement.parentNode.insertBefore(editorElement, preElement)
|
||||
editor.setVisible(true)
|
||||
}
|
||||
|
||||
promises.push(editorCallback(editorElement, preElement))
|
||||
}
|
||||
return Promise.all(promises)
|
||||
}
|
||||
|
||||
var makeAtomEditorNonInteractive = function (editorElement, preElement) {
|
||||
preElement.remove()
|
||||
editorElement.setAttributeNode(document.createAttribute('gutter-hidden')) // Hide gutter
|
||||
editorElement.removeAttribute('tabindex') // Make read-only
|
||||
function makeAtomEditorNonInteractive(editorElement) {
|
||||
editorElement.setAttributeNode(document.createAttribute('gutter-hidden'))
|
||||
editorElement.removeAttribute('tabindex')
|
||||
|
||||
// Remove line decorations from code blocks.
|
||||
for (const cursorLineDecoration of editorElement.getModel()
|
||||
@ -276,9 +367,12 @@ var makeAtomEditorNonInteractive = function (editorElement, preElement) {
|
||||
}
|
||||
}
|
||||
|
||||
var convertAtomEditorToStandardElement = (editorElement, preElement) => {
|
||||
function convertAtomEditorToStandardElement(editorElement, preElement) {
|
||||
return new Promise(function (resolve) {
|
||||
const editor = editorElement.getModel()
|
||||
// In this code path, we're transplanting the highlighted editor HTML into
|
||||
// the existing `pre` element, so we should empty its contents first.
|
||||
preElement.innerHTML = ''
|
||||
const done = () =>
|
||||
editor.component.getNextUpdatePromise().then(function () {
|
||||
for (const line of editorElement.querySelectorAll(
|
||||
|
14
packages/markdown-preview/package-lock.json
generated
14
packages/markdown-preview/package-lock.json
generated
@ -16,6 +16,7 @@
|
||||
"fs-plus": "^3.0.0",
|
||||
"github-markdown-css": "^5.5.1",
|
||||
"marked": "5.0.3",
|
||||
"morphdom": "^2.7.2",
|
||||
"underscore-plus": "^1.0.0",
|
||||
"yaml-front-matter": "^4.1.1"
|
||||
},
|
||||
@ -23,7 +24,8 @@
|
||||
"temp": "^0.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"atom": "*"
|
||||
"atom": "*",
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
@ -278,6 +280,11 @@
|
||||
"mkdirp": "bin/cmd.js"
|
||||
}
|
||||
},
|
||||
"node_modules/morphdom": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/morphdom/-/morphdom-2.7.2.tgz",
|
||||
"integrity": "sha512-Dqb/lHFyTi7SZpY0a5R4I/0Edo+iPMbaUexsHHsLAByyixCDiLHPHyVoKVmrpL0THcT7V9Cgev9y21TQYq6wQg=="
|
||||
},
|
||||
"node_modules/nth-check": {
|
||||
"version": "1.0.2",
|
||||
"license": "BSD-2-Clause",
|
||||
@ -567,6 +574,11 @@
|
||||
"minimist": "0.0.8"
|
||||
}
|
||||
},
|
||||
"morphdom": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/morphdom/-/morphdom-2.7.2.tgz",
|
||||
"integrity": "sha512-Dqb/lHFyTi7SZpY0a5R4I/0Edo+iPMbaUexsHHsLAByyixCDiLHPHyVoKVmrpL0THcT7V9Cgev9y21TQYq6wQg=="
|
||||
},
|
||||
"nth-check": {
|
||||
"version": "1.0.2",
|
||||
"requires": {
|
||||
|
@ -6,7 +6,8 @@
|
||||
"repository": "https://github.com/pulsar-edit/pulsar",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"atom": "*"
|
||||
"atom": "*",
|
||||
"node": ">=12"
|
||||
},
|
||||
"scripts": {
|
||||
"generate-github-markdown-css": "node scripts/generate-github-markdown-css.js"
|
||||
@ -19,6 +20,7 @@
|
||||
"fs-plus": "^3.0.0",
|
||||
"github-markdown-css": "^5.5.1",
|
||||
"marked": "5.0.3",
|
||||
"morphdom": "^2.7.2",
|
||||
"underscore-plus": "^1.0.0",
|
||||
"yaml-front-matter": "^4.1.1"
|
||||
},
|
||||
|
@ -41,6 +41,13 @@ describe('Markdown Preview', function () {
|
||||
.getActiveItem())
|
||||
)
|
||||
|
||||
waitsFor(
|
||||
'preview to finish loading',
|
||||
() => {
|
||||
return !preview.element.classList.contains('loading')
|
||||
}
|
||||
)
|
||||
|
||||
runs(() => {
|
||||
expect(preview).toBeInstanceOf(MarkdownPreviewView)
|
||||
expect(preview.getPath()).toBe(
|
||||
|
@ -198,12 +198,15 @@ function f(x) {
|
||||
() => renderSpy.callCount === 1
|
||||
)
|
||||
|
||||
runs(function () {
|
||||
const rubyEditor = preview.element.querySelector(
|
||||
"atom-text-editor[data-grammar='source ruby']"
|
||||
)
|
||||
expect(rubyEditor).toBeNull()
|
||||
})
|
||||
waitsFor(
|
||||
'atom-text-editor to reassign all language modes after re-render',
|
||||
() => {
|
||||
let rubyEditor = preview.element.querySelector(
|
||||
"atom-text-editor[data-grammar='source ruby']"
|
||||
)
|
||||
return rubyEditor == null
|
||||
}
|
||||
)
|
||||
|
||||
waitsForPromise(() => atom.packages.activatePackage('language-ruby'))
|
||||
|
||||
|
@ -2,6 +2,14 @@
|
||||
// Global Markdown Preview styles
|
||||
|
||||
.markdown-preview {
|
||||
contain: paint;
|
||||
|
||||
// Hide a `pre` that comes directly after an `atom-text-editor` because the
|
||||
// `atom-text-editor` is the syntax-highlighted representation.
|
||||
atom-text-editor + pre {
|
||||
display: none;
|
||||
}
|
||||
|
||||
atom-text-editor {
|
||||
// only show scrollbars on hover
|
||||
.scrollbars-visible-always & {
|
||||
@ -28,14 +36,38 @@
|
||||
.task-list-item {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
&.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
// `.loading` on the preview element automatically shows the spinner/text.
|
||||
// We add a slight animation delay so that, when the preview content is
|
||||
// quick to appear (as usually happens), the spinner won't be shown. It
|
||||
// only shows up when preview content takes a while to render.
|
||||
&:before {
|
||||
display: block;
|
||||
content: 'Loading Markdown…';
|
||||
margin: auto;
|
||||
background-image: url(images/octocat-spinner-128.gif);
|
||||
background-repeat: no-repeat;
|
||||
background-size: 64px;
|
||||
background-position: top center;
|
||||
padding-top: 70px;
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
animation-duration: 1s;
|
||||
animation-name: appear-after-short-delay;
|
||||
animation-delay: 0.75s;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.markdown-spinner {
|
||||
margin: auto;
|
||||
background-image: url(images/octocat-spinner-128.gif);
|
||||
background-repeat: no-repeat;
|
||||
background-size: 64px;
|
||||
background-position: top center;
|
||||
padding-top: 70px;
|
||||
text-align: center;
|
||||
// Not an actual animation; we just use an animation so that it can appear
|
||||
// after a short delay.
|
||||
@keyframes appear-after-short-delay {
|
||||
0% { opacity: 1; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
1
packages/snippets/.eslintignore
Normal file
1
packages/snippets/.eslintignore
Normal file
@ -0,0 +1 @@
|
||||
*.pegjs
|
13
packages/snippets/.eslintrc
Normal file
13
packages/snippets/.eslintrc
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"parserOptions": {
|
||||
"sourceType": "module",
|
||||
"ecmaVersion": 2022
|
||||
},
|
||||
"rules": {
|
||||
"indent": ["error", 2],
|
||||
"linebreak-style": ["error", "unix"],
|
||||
"object-curly-spacing": ["error", "never"],
|
||||
"space-before-function-paren": ["error", "always"],
|
||||
"semi": ["error", "never"]
|
||||
}
|
||||
}
|
2
packages/snippets/.gitignore
vendored
Normal file
2
packages/snippets/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
.tool-versions
|
16
packages/snippets/.pairs
Normal file
16
packages/snippets/.pairs
Normal file
@ -0,0 +1,16 @@
|
||||
pairs:
|
||||
ns: Nathan Sobo; nathan
|
||||
cj: Corey Johnson; cj
|
||||
dg: David Graham; dgraham
|
||||
ks: Kevin Sawicki; kevin
|
||||
jc: Jerry Cheung; jerry
|
||||
bl: Brian Lopez; brian
|
||||
jp: Justin Palmer; justin
|
||||
gt: Garen Torikian; garen
|
||||
mc: Matt Colyer; mcolyer
|
||||
bo: Ben Ogle; benogle
|
||||
jr: Jason Rudolph; jasonrudolph
|
||||
jl: Jessica Lord; jlord
|
||||
email:
|
||||
domain: github.com
|
||||
#global: true
|
1
packages/snippets/CONTRIBUTING.md
Normal file
1
packages/snippets/CONTRIBUTING.md
Normal file
@ -0,0 +1 @@
|
||||
[See how you can contribute](https://github.com/pulsar-edit/.github/blob/main/CONTRIBUTING.md)
|
208
packages/snippets/README.md
Normal file
208
packages/snippets/README.md
Normal file
@ -0,0 +1,208 @@
|
||||
# Snippets package
|
||||
|
||||
Expand snippets matching the current prefix with <kbd>tab</kbd> in Pulsar.
|
||||
|
||||
To add your own snippets, select the _Pulsar > Snippets..._ menu option if you're using macOS, or the _File > Snippets..._ menu option if you're using Windows, or the _Edit > Snippets..._ menu option if you are using Linux.
|
||||
|
||||
## Snippet Format
|
||||
|
||||
Snippets files are stored in a package's `snippets/` folder and also loaded from `~/.pulsar/snippets.cson`. They can be either `.json` or `.cson` file types.
|
||||
|
||||
```coffee
|
||||
'.source.js':
|
||||
'console.log':
|
||||
'prefix': 'log'
|
||||
'command': 'insert-console-log'
|
||||
'body': 'console.log(${1:"crash"});$2'
|
||||
```
|
||||
|
||||
The outermost keys are the selectors where these snippets should be active, prefixed with a period (`.`) (details below).
|
||||
|
||||
The next level of keys are the snippet names. Because this is object notation, each snippet must have a different name.
|
||||
|
||||
Under each snippet name is a `body` to insert when the snippet is triggered.
|
||||
|
||||
`$` followed by a number are the tabs stops which can be cycled between by pressing <kbd>Tab</kbd> once a snippet has been triggered.
|
||||
|
||||
The above example adds a `console.log` snippet to JavaScript files that would expand to:
|
||||
|
||||
```js
|
||||
console.log("crash");
|
||||
```
|
||||
|
||||
The string `"crash"` would be initially selected and pressing tab again would place the cursor after the `;`
|
||||
|
||||
A snippet specifies how it can be triggered. Thus it must provide **at least one** of the following keys:
|
||||
|
||||
### The ‘prefix’ key
|
||||
|
||||
If a `prefix` is defined, it specifies a string that can trigger the snippet. In the above example, typing `log` (as its own word) and then pressing <kbd>Tab</kbd> would replace `log` with the string `console.log("crash")` as described above.
|
||||
|
||||
Prefix completions can be suggested if partially typed thanks to the `autocomplete-snippets` package.
|
||||
|
||||
### The ‘command’ key
|
||||
|
||||
If a `command` is defined, it specifies a command name that can trigger the snippet. That command can be invoked from the command palette or mapped to a keyboard shortcut via your `keymap.cson`.
|
||||
|
||||
If a package called `some-package` had defined that snippet, it would be available in the keymap as `some-package:insert-console-log`, or in the command palette as **Some Package: Insert Console Log**.
|
||||
|
||||
If you defined the `console.log` snippet described above in your own `snippets.cson`, it could be referenced in a keymap file as `snippets:insert-console-log`, or in the command palette as **Snippets: Insert Console Log**.
|
||||
|
||||
Invoking the command would insert the snippet at the cursor, replacing any text that may be selected.
|
||||
|
||||
Snippet command names must be unique. They can’t conflict with each other, nor can they conflict with any other commands that have been defined. If there is such a conflict, you’ll see an error notification describing the problem.
|
||||
|
||||
### Optional parameters
|
||||
|
||||
These parameters are meant to provide extra information about your snippet to [autocomplete-plus](https://github.com/atom/autocomplete-plus/wiki/Provider-API).
|
||||
|
||||
* `leftLabel` will add text to the left part of the autocomplete results box.
|
||||
* `leftLabelHTML` will overwrite what's in `leftLabel` and allow you to use a bit of CSS such as `color`.
|
||||
* `rightLabelHTML`. By default, in the right part of the results box you will see the name of the snippet. When using `rightLabelHTML` the name of the snippet will no longer be displayed, and you will be able to use a bit of CSS.
|
||||
* `description` will add text to a description box under the autocomplete results list.
|
||||
* `descriptionMoreURL` URL to the documentation of the snippet.
|
||||
|
||||
![autocomplete-description](http://i.imgur.com/cvI2lOq.png)
|
||||
|
||||
Example:
|
||||
```coffee
|
||||
'.source.js':
|
||||
'console.log':
|
||||
'prefix': 'log'
|
||||
'body': 'console.log(${1:"crash"});$2'
|
||||
'description': 'Output data to the console'
|
||||
'rightLabelHTML': '<span style="color:#ff0">JS</span>'
|
||||
```
|
||||
|
||||
### Determining the correct scope for a snippet
|
||||
|
||||
The outmost key of a snippet is the “scope” that you want the descendent snippets to be available in. The key should be prefixed with a period (`text.html.basic` → `.text.html.basic`). You can find out the correct scope by opening the Settings (<kbd>cmd-,</kbd> on macOS) and selecting the corresponding *Language [xxx]* package. For example, here’s the settings page for `language-html`:
|
||||
|
||||
![Screenshot of Language Html settings](https://cloud.githubusercontent.com/assets/1038121/5137632/126beb66-70f2-11e4-839b-bc7e84103f67.png)
|
||||
|
||||
If it's difficult to determine the package handling the file type in question (for example, for `.md`-documents), you can use another approach:
|
||||
|
||||
1. Put your cursor in a file in which you want the snippet to be available.
|
||||
2. Open the [Command Palette](https://github.com/pulsar-edit/command-palette)
|
||||
(<kbd>cmd-shift-p</kbd> or <kbd>ctrl-shift-p</kbd>).
|
||||
3. Run the `Editor: Log Cursor Scope` command.
|
||||
|
||||
This will trigger a notification which will contain a list of scopes. The first scope that's listed is the scope for that language. Here are some examples: `source.coffee`, `text.plain`, `text.html.basic`.
|
||||
|
||||
## Snippet syntax
|
||||
|
||||
This package supports a subset of the features of TextMate snippets, [documented here](http://manual.macromates.com/en/snippets), as well as most features described in the [LSP specification][lsp] and [supported by VSCode][vscode].
|
||||
|
||||
The following features from TextMate snippets are not yet supported:
|
||||
|
||||
* Interpolated shell code can’t reliably be supported cross-platform, and is probably a bad idea anyway. No other editors that support snippets have adopted this feature, and Pulsar won’t either.
|
||||
|
||||
The following features from VSCode snippets are not yet supported:
|
||||
|
||||
* “Choice” syntax like `${1|one,two,three|}` requires that the autocomplete engine pop up a menu to offer the user a choice between the available placeholder options. This may be supported in the future, but right now Pulsar effectively converts this to `${1:one}`, treating the first choice as a conventional placeholder.
|
||||
|
||||
### Variables
|
||||
|
||||
Pulsar snippets support all of the variables mentioned in the [LSP specification][lsp], plus many of the variables [supported by VSCode][vscode].
|
||||
|
||||
Variables can be referenced with `$`, either without braces (`$CLIPBOARD`) or with braces (`${CLIPBOARD}`). Variables can also have fallback values (`${CLIPBOARD:http://example.com}`), simple flag-based transformations (`${CLIPBOARD:/upcase}`), or `sed`-style transformations (`${CLIPBOARD/ /_/g}`).
|
||||
|
||||
One of the most useful is `TM_SELECTED_TEXT`, which represents whatever text was selected when the snippet was invoked. (Naturally, this can only happen when a snippet is invoked via command or key shortcut, rather than by typing in a <kbd>Tab</kbd> trigger.)
|
||||
|
||||
Others that can be useful:
|
||||
|
||||
* `TM_FILENAME`: The name of the current file (`foo.rb`).
|
||||
* `TM_FILENAME_BASE`: The name of the current file, but without its extension (`foo`).
|
||||
* `TM_FILEPATH`: The entire path on disk to the current file.
|
||||
* `TM_CURRENT_LINE`: The entire current line that the cursor is sitting on.
|
||||
* `TM_CURRENT_WORD`: The entire word that the cursor is within or adjacent to, as interpreted by `cursor.getCurrentWordBufferRange`.
|
||||
* `CLIPBOARD`: The current contents of the clipboard.
|
||||
* `CURRENT_YEAR`, `CURRENT_MONTH`, et cetera: referneces to the current date and time in various formats.
|
||||
* `LINE_COMMENT`, `BLOCK_COMMENT_START`, `BLOCK_COMMENT_END`: uses the correct comment delimiters for whatever language you’re in.
|
||||
|
||||
Any variable that has no value — for instance, `TM_FILENAME` on an untitled document, or `LINE_COMMENT` in a CSS file — will resolve to an empty string.
|
||||
|
||||
#### Variable transformation flags
|
||||
|
||||
Pulsar supports the three flags defined in the [LSP snippets specification][lsp] and two other flags that are [implemented in VSCode][vscode]:
|
||||
|
||||
* `/upcase` (`foo` → `FOO`)
|
||||
* `/downcase` (`BAR` → `bar`)
|
||||
* `/capitalize` (`lorem ipsum dolor` → `Lorem ipsum dolor`) *(first letter uppercased; rest of input left intact)*
|
||||
* `/camelcase` (`foo bar` → `fooBar`, `lorem-ipsum.dolor` → `loremIpsumDolor`)
|
||||
* `/pascalcase` (`foo bar` → `FooBar`, `lorem-ipsum.dolor` → `LoremIpsumDolor`)
|
||||
|
||||
It also supports two other common transformations:
|
||||
|
||||
* `/snakecase` (`foo bar` → `foo_bar`, `lorem-ipsum.dolor` → `lorem_ipsum_dolor`)
|
||||
* `/kebabcase` (`foo bar` → `foo-bar`, `lorem-ipsum.dolor` → `lorem-ipsum-dolor`)
|
||||
|
||||
These transformation flags can also be applied on backreferences in `sed`-style replacements for transformed tab stops. Given the following example snippet body…
|
||||
|
||||
```
|
||||
[$1] becomes [${1/(.*)/${1:/upcase}/}]
|
||||
```
|
||||
|
||||
…invoking the snippet and typing `Lorem ipsum dolor` will produce:
|
||||
|
||||
```
|
||||
[Lorem ipsum dolor] becomes [LOREM IPSUM DOLOR]
|
||||
```
|
||||
|
||||
|
||||
#### Variable caveats
|
||||
|
||||
* `WORKSPACE_NAME`, `WORKSPACE_FOLDER`, and `RELATIVE_PATH` all rely on the presence of a root project folder, but a Pulsar project can technically have multiple root folders. While this is rare, it is handled by `snippets` as follows: whichever project path is an ancestor of the currently active file is treated as the project root — or the first one found if multiple roots are ancestors.
|
||||
* `WORKSPACE_NAME` in VSCode refers to “the name of the opened workspace or folder.” In the former case, this appears to mean bundled projects with a `.code-workspace` file extension — which have no Pulsar equivalent. Instead, `WORKSPACE_NAME` will always refer to the last path component of your project’s root directory as defined above.
|
||||
|
||||
#### Variables that are not yet supported
|
||||
|
||||
Of the variables supported by VSCode, Pulsar does not yet support:
|
||||
|
||||
* `UUID` (Will automatically be supported when Pulsar uses a version of Electron that has native `crypto.randomUUID`.)
|
||||
|
||||
## Multi-line Snippet Body
|
||||
|
||||
You can also use multi-line syntax using `"""` for larger templates:
|
||||
|
||||
```coffee
|
||||
'.source.js':
|
||||
'if, else if, else':
|
||||
'prefix': 'ieie'
|
||||
'body': """
|
||||
if (${1:true}) {
|
||||
$2
|
||||
} else if (${3:false}) {
|
||||
$4
|
||||
} else {
|
||||
$5
|
||||
}
|
||||
"""
|
||||
```
|
||||
|
||||
## Escaping Characters
|
||||
|
||||
Including a literal closing brace inside the text provided by a snippet's tab stop will close that tab stop early. To prevent that, escape the brace with two backslashes, like so:
|
||||
|
||||
```coffee
|
||||
'.source.js':
|
||||
'function':
|
||||
'prefix': 'funct'
|
||||
'body': """
|
||||
${1:function () {
|
||||
statements;
|
||||
\\}
|
||||
this line is also included in the snippet tab;
|
||||
}
|
||||
"""
|
||||
```
|
||||
|
||||
Likewise, if your snippet includes literal references to `$` or `{`, you may have to escape those with two backslashes as well, depending on the context.
|
||||
|
||||
## Multiple snippets for the same scope
|
||||
|
||||
Snippets for the same scope must be placed within the same key. See [this section of the Pulsar Flight Manual](https://pulsar-edit.dev/docs/launch-manual/sections/using-pulsar/#configuring-with-cson) for more information.
|
||||
|
||||
|
||||
[lsp]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#variables
|
||||
[vscode]: https://code.visualstudio.com/docs/editor/userdefinedsnippets#_variables
|
2
packages/snippets/keymaps/snippets-1.cson
Normal file
2
packages/snippets/keymaps/snippets-1.cson
Normal file
@ -0,0 +1,2 @@
|
||||
'atom-text-editor:not([mini])':
|
||||
'tab': 'snippets:expand'
|
6
packages/snippets/keymaps/snippets-2.cson
Normal file
6
packages/snippets/keymaps/snippets-2.cson
Normal file
@ -0,0 +1,6 @@
|
||||
# it's critical that these bindings be loaded after those snippets-1 so they
|
||||
# are later in the cascade, hence breaking the keymap into 2 files
|
||||
|
||||
'atom-text-editor:not([mini])':
|
||||
'tab': 'snippets:next-tab-stop'
|
||||
'shift-tab': 'snippets:previous-tab-stop'
|
76
packages/snippets/lib/editor-store.js
Normal file
76
packages/snippets/lib/editor-store.js
Normal file
@ -0,0 +1,76 @@
|
||||
const SnippetHistoryProvider = require('./snippet-history-provider')
|
||||
|
||||
class EditorStore {
|
||||
constructor (editor) {
|
||||
this.editor = editor
|
||||
this.buffer = this.editor.getBuffer()
|
||||
this.observer = null
|
||||
this.checkpoint = null
|
||||
this.expansions = []
|
||||
this.existingHistoryProvider = null
|
||||
}
|
||||
|
||||
getExpansions () {
|
||||
return this.expansions
|
||||
}
|
||||
|
||||
setExpansions (list) {
|
||||
this.expansions = list
|
||||
}
|
||||
|
||||
clearExpansions () {
|
||||
this.expansions = []
|
||||
}
|
||||
|
||||
addExpansion (snippetExpansion) {
|
||||
this.expansions.push(snippetExpansion)
|
||||
}
|
||||
|
||||
observeHistory (delegates) {
|
||||
let isObservingHistory = this.existingHistoryProvider != null
|
||||
if (isObservingHistory) {
|
||||
return
|
||||
} else {
|
||||
this.existingHistoryProvider = this.buffer.historyProvider
|
||||
}
|
||||
|
||||
const newProvider = SnippetHistoryProvider(this.existingHistoryProvider, delegates)
|
||||
this.buffer.setHistoryProvider(newProvider)
|
||||
}
|
||||
|
||||
stopObservingHistory (editor) {
|
||||
if (this.existingHistoryProvider == null) { return }
|
||||
this.buffer.setHistoryProvider(this.existingHistoryProvider)
|
||||
this.existingHistoryProvider = null
|
||||
}
|
||||
|
||||
observe (callback) {
|
||||
if (this.observer != null) { this.observer.dispose() }
|
||||
this.observer = this.buffer.onDidChangeText(callback)
|
||||
}
|
||||
|
||||
stopObserving () {
|
||||
if (this.observer == null) { return false }
|
||||
this.observer.dispose()
|
||||
this.observer = null
|
||||
return true
|
||||
}
|
||||
|
||||
makeCheckpoint () {
|
||||
const existing = this.checkpoint
|
||||
if (existing) {
|
||||
this.buffer.groupChangesSinceCheckpoint(existing)
|
||||
}
|
||||
this.checkpoint = this.buffer.createCheckpoint()
|
||||
}
|
||||
}
|
||||
|
||||
EditorStore.store = new WeakMap()
|
||||
EditorStore.findOrCreate = function (editor) {
|
||||
if (!this.store.has(editor)) {
|
||||
this.store.set(editor, new EditorStore(editor))
|
||||
}
|
||||
return this.store.get(editor)
|
||||
}
|
||||
|
||||
module.exports = EditorStore
|
13
packages/snippets/lib/helpers.js
Normal file
13
packages/snippets/lib/helpers.js
Normal file
@ -0,0 +1,13 @@
|
||||
/** @babel */
|
||||
|
||||
import path from 'path'
|
||||
|
||||
export function getPackageRoot() {
|
||||
const {resourcePath} = atom.getLoadSettings()
|
||||
const currentFileWasRequiredFromSnapshot = !path.isAbsolute(__dirname)
|
||||
if (currentFileWasRequiredFromSnapshot) {
|
||||
return path.join(resourcePath, 'node_modules', 'snippets')
|
||||
} else {
|
||||
return path.resolve(__dirname, '..')
|
||||
}
|
||||
}
|
31
packages/snippets/lib/insertion.js
Normal file
31
packages/snippets/lib/insertion.js
Normal file
@ -0,0 +1,31 @@
|
||||
const Replacer = require('./replacer')
|
||||
|
||||
class Insertion {
|
||||
constructor ({range, substitution, references}) {
|
||||
this.range = range
|
||||
this.substitution = substitution
|
||||
this.references = references
|
||||
if (substitution) {
|
||||
if (substitution.replace === undefined) {
|
||||
substitution.replace = ''
|
||||
}
|
||||
this.replacer = new Replacer(substitution.replace)
|
||||
}
|
||||
}
|
||||
|
||||
isTransformation () {
|
||||
return !!this.substitution
|
||||
}
|
||||
|
||||
transform (input) {
|
||||
let {substitution} = this
|
||||
if (!substitution) { return input }
|
||||
this.replacer.resetFlags()
|
||||
return input.replace(substitution.find, (...args) => {
|
||||
let result = this.replacer.replace(...args)
|
||||
return result
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Insertion
|
107
packages/snippets/lib/replacer.js
Normal file
107
packages/snippets/lib/replacer.js
Normal file
@ -0,0 +1,107 @@
|
||||
const FLAGS = require('./simple-transformations')
|
||||
|
||||
const ESCAPES = {
|
||||
u: (flags) => {
|
||||
flags.lowercaseNext = false
|
||||
flags.uppercaseNext = true
|
||||
},
|
||||
l: (flags) => {
|
||||
flags.uppercaseNext = false
|
||||
flags.lowercaseNext = true
|
||||
},
|
||||
U: (flags) => {
|
||||
flags.lowercaseAll = false
|
||||
flags.uppercaseAll = true
|
||||
},
|
||||
L: (flags) => {
|
||||
flags.uppercaseAll = false
|
||||
flags.lowercaseAll = true
|
||||
},
|
||||
E: (flags) => {
|
||||
flags.uppercaseAll = false
|
||||
flags.lowercaseAll = false
|
||||
},
|
||||
r: (flags, result) => {
|
||||
result.push('\\r')
|
||||
},
|
||||
n: (flags, result) => {
|
||||
result.push('\\n')
|
||||
},
|
||||
$: (flags, result) => {
|
||||
result.push('$')
|
||||
}
|
||||
}
|
||||
|
||||
function transformTextWithFlags (str, flags) {
|
||||
if (flags.uppercaseAll) {
|
||||
return str.toUpperCase()
|
||||
} else if (flags.lowercaseAll) {
|
||||
return str.toLowerCase()
|
||||
} else if (flags.uppercaseNext) {
|
||||
flags.uppercaseNext = false
|
||||
return str.replace(/^./, s => s.toUpperCase())
|
||||
} else if (flags.lowercaseNext) {
|
||||
return str.replace(/^./, s => s.toLowerCase())
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
|
||||
// `Replacer` handles shared substitution semantics for tabstop and variable
|
||||
// transformations.
|
||||
class Replacer {
|
||||
constructor (tokens) {
|
||||
this.tokens = [...tokens]
|
||||
this.resetFlags()
|
||||
}
|
||||
|
||||
resetFlags () {
|
||||
this.flags = {
|
||||
uppercaseAll: false,
|
||||
lowercaseAll: false,
|
||||
uppercaseNext: false,
|
||||
lowercaseNext: false
|
||||
}
|
||||
}
|
||||
|
||||
replace (...match) {
|
||||
let result = []
|
||||
|
||||
function handleToken (token) {
|
||||
if (typeof token === 'string') {
|
||||
result.push(transformTextWithFlags(token, this.flags))
|
||||
} else if (token.escape) {
|
||||
ESCAPES[token.escape](this.flags, result)
|
||||
} else if (token.backreference) {
|
||||
if (token.transform && (token.transform in FLAGS)) {
|
||||
let transformed = FLAGS[token.transform](match[token.backreference])
|
||||
result.push(transformed)
|
||||
} else {
|
||||
let {iftext, elsetext} = token
|
||||
if (iftext != null && elsetext != null) {
|
||||
// If-else syntax makes choices based on the presence or absence of a
|
||||
// capture group backreference.
|
||||
let m = match[token.backreference]
|
||||
let tokenToHandle = m ? iftext : elsetext
|
||||
if (Array.isArray(tokenToHandle)) {
|
||||
result.push(...tokenToHandle.map(handleToken.bind(this)))
|
||||
} else {
|
||||
result.push(handleToken.call(this, tokenToHandle))
|
||||
}
|
||||
} else {
|
||||
let transformed = transformTextWithFlags(
|
||||
match[token.backreference],
|
||||
this.flags
|
||||
)
|
||||
result.push(transformed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.tokens.forEach(handleToken.bind(this))
|
||||
return result.join('')
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Replacer
|
47
packages/snippets/lib/simple-transformations.js
Normal file
47
packages/snippets/lib/simple-transformations.js
Normal file
@ -0,0 +1,47 @@
|
||||
// Simple transformation flags that can convert a string in various ways. They
|
||||
// are specified for variables and for transforming substitution
|
||||
// backreferences, so we need to use them in two places.
|
||||
const FLAGS = {
|
||||
// These are included in the LSP spec.
|
||||
upcase: value => (value || '').toLocaleUpperCase(),
|
||||
downcase: value => (value || '').toLocaleLowerCase(),
|
||||
capitalize: (value) => {
|
||||
return !value ? '' : (value[0].toLocaleUpperCase() + value.substr(1))
|
||||
},
|
||||
|
||||
// These are supported by VSCode.
|
||||
pascalcase (value) {
|
||||
const match = value.match(/[a-z0-9]+/gi)
|
||||
if (!match) {
|
||||
return value
|
||||
}
|
||||
return match.map(word => {
|
||||
return word.charAt(0).toUpperCase() + word.substr(1)
|
||||
}).join('')
|
||||
},
|
||||
camelcase (value) {
|
||||
const match = value.match(/[a-z0-9]+/gi)
|
||||
if (!match) {
|
||||
return value
|
||||
}
|
||||
return match.map((word, index) => {
|
||||
if (index === 0) {
|
||||
return word.charAt(0).toLowerCase() + word.substr(1)
|
||||
}
|
||||
return word.charAt(0).toUpperCase() + word.substr(1)
|
||||
}).join('')
|
||||
},
|
||||
|
||||
// No reason not to implement these also.
|
||||
snakecase (value) {
|
||||
let camel = this.camelcase(value)
|
||||
return camel.replace(/[A-Z]/g, (match) => `_${match.toLowerCase()}`)
|
||||
},
|
||||
|
||||
kebabcase (value) {
|
||||
let camel = this.camelcase(value)
|
||||
return camel.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FLAGS
|
18
packages/snippets/lib/snippet-body-parser.js
Normal file
18
packages/snippets/lib/snippet-body-parser.js
Normal file
@ -0,0 +1,18 @@
|
||||
let parser
|
||||
try {
|
||||
// When the .pegjs file is stable and you're ready for release, run `npx
|
||||
// pegjs lib/snippet-body.pegjs` to compile the parser. That way end users
|
||||
// won't have to pay the cost of runtime evaluation.
|
||||
parser = require('./snippet-body')
|
||||
} catch (error) {
|
||||
// When you're iterating on the parser, rename or delete `snippet-body.js` so
|
||||
// you can make changes to the .pegjs file and have them reflected after a
|
||||
// window reload.
|
||||
const fs = require('fs')
|
||||
const PEG = require('pegjs')
|
||||
|
||||
const grammarSrc = fs.readFileSync(require.resolve('./snippet-body.pegjs'), 'utf8')
|
||||
parser = PEG.generate(grammarSrc)
|
||||
}
|
||||
|
||||
module.exports = parser
|
2948
packages/snippets/lib/snippet-body.js
Normal file
2948
packages/snippets/lib/snippet-body.js
Normal file
File diff suppressed because it is too large
Load Diff
231
packages/snippets/lib/snippet-body.pegjs
Normal file
231
packages/snippets/lib/snippet-body.pegjs
Normal file
@ -0,0 +1,231 @@
|
||||
|
||||
{
|
||||
// If you're making changes to this file, be sure to re-compile afterward
|
||||
// using the instructions in `snippet-body-parser.js`.
|
||||
|
||||
function makeInteger(i) {
|
||||
return parseInt(i.join(''), 10);
|
||||
}
|
||||
|
||||
function coalesce (parts) {
|
||||
const result = [];
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
const ri = result.length - 1;
|
||||
if (typeof part === 'string' && typeof result[ri] === 'string') {
|
||||
result[ri] = result[ri] + part;
|
||||
} else {
|
||||
result.push(part);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function unwrap (val) {
|
||||
let shouldUnwrap = Array.isArray(val) && val.length === 1 && typeof val[0] === 'string';
|
||||
return shouldUnwrap ? val[0] : val;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
bodyContent = content:(tabstop / choice / variable / text)* { return content; }
|
||||
|
||||
innerBodyContent = content:(tabstop / choice / variable / nonCloseBraceText)* { return content; }
|
||||
|
||||
tabstop = simpleTabstop / tabstopWithoutPlaceholder / tabstopWithPlaceholder / tabstopWithTransform
|
||||
|
||||
simpleTabstop = '$' index:int {
|
||||
return {index: makeInteger(index), content: []}
|
||||
}
|
||||
|
||||
tabstopWithoutPlaceholder = '${' index:int '}' {
|
||||
return {index: makeInteger(index), content: []}
|
||||
}
|
||||
|
||||
tabstopWithPlaceholder = '${' index:int ':' content:innerBodyContent '}' {
|
||||
return {index: makeInteger(index), content: content}
|
||||
}
|
||||
|
||||
tabstopWithTransform = '${' index:int substitution:transform '}' {
|
||||
return {
|
||||
index: makeInteger(index),
|
||||
content: [],
|
||||
substitution: substitution
|
||||
}
|
||||
}
|
||||
|
||||
choice = '${' index:int '|' choice:choicecontents '|}' {
|
||||
// Choice syntax requires an autocompleter to offer the user the options. As
|
||||
// a fallback, we can take the first option and treat it as a placeholder.
|
||||
const content = choice.length > 0 ? [choice[0]] : []
|
||||
return {index: makeInteger(index), choice: choice, content: content}
|
||||
}
|
||||
|
||||
choicecontents = elem:choicetext rest:(',' val:choicetext { return val } )* {
|
||||
return [elem, ...rest]
|
||||
}
|
||||
|
||||
choicetext = choicetext:(choiceEscaped / [^|,] / barred:('|' &[^}]) { return barred.join('') } )+ {
|
||||
return choicetext.join('')
|
||||
}
|
||||
|
||||
transform = '/' regex:regexString '/' replace:replace '/' flags:flags {
|
||||
return {find: new RegExp(regex, flags), replace: replace}
|
||||
}
|
||||
|
||||
regexString = regex:(escaped / [^/])* {
|
||||
return regex.join('')
|
||||
}
|
||||
|
||||
replace = (format / replacetext)*
|
||||
|
||||
format = simpleFormat / formatWithoutPlaceholder / formatWithCaseTransform / formatWithIf / formatWithIfElse / formatWithElse / formatEscape / formatWithIfElseAlt / formatWithIfAlt
|
||||
|
||||
simpleFormat = '$' index:int {
|
||||
return {backreference: makeInteger(index)}
|
||||
}
|
||||
|
||||
formatWithoutPlaceholder = '${' index:int '}' {
|
||||
return {backreference: makeInteger(index)}
|
||||
}
|
||||
|
||||
formatWithCaseTransform = '${' index:int ':' caseTransform:caseTransform '}' {
|
||||
return {backreference: makeInteger(index), transform: caseTransform}
|
||||
}
|
||||
|
||||
formatWithIf = '${' index:int ':+' iftext:(ifElseText / '') '}' {
|
||||
return {backreference: makeInteger(index), iftext: unwrap(iftext), elsetext: ''}
|
||||
}
|
||||
|
||||
formatWithIfAlt = '(?' index:int ':' iftext:(ifTextAlt / '') ')' {
|
||||
return {backreference: makeInteger(index), iftext: unwrap(iftext), elseText: '' }
|
||||
}
|
||||
|
||||
formatWithElse = '${' index:int (':-' / ':') elsetext:(ifElseText / '') '}' {
|
||||
return {backreference: makeInteger(index), iftext: '', elsetext: unwrap(elsetext)}
|
||||
}
|
||||
|
||||
// Variable interpolation if-else; conditional clause queries the presence of a
|
||||
// specific tabstop value.
|
||||
formatWithIfElse = '${' index:int ':?' iftext:ifText ':' elsetext:(ifElseText / '') '}' {
|
||||
return {backreference: makeInteger(index), iftext: iftext, elsetext: elsetext}
|
||||
}
|
||||
|
||||
// Substitution if-else; conditional clause tests whether a given regex capture
|
||||
// group matched anything.
|
||||
formatWithIfElseAlt = '(?' index:int ':' iftext:(ifTextAlt / '') ':' elsetext:(elseTextAlt / '') ')' {
|
||||
return {backreference: makeInteger(index), iftext: iftext, elsetext: elsetext}
|
||||
}
|
||||
|
||||
nonColonText = text:('\\:' { return ':' } / escaped / [^:])* {
|
||||
return text.join('')
|
||||
}
|
||||
|
||||
formatEscape = '\\' flag:[ULulErn] {
|
||||
return {escape: flag}
|
||||
}
|
||||
|
||||
caseTransform = '/' type:[a-zA-Z]* {
|
||||
return type.join('')
|
||||
}
|
||||
|
||||
replacetext = replacetext:(!formatEscape char:escaped { return char } / !format char:[^/] { return char })+ {
|
||||
return replacetext.join('')
|
||||
}
|
||||
|
||||
variable = simpleVariable / variableWithSimpleTransform / variableWithoutPlaceholder / variableWithPlaceholder / variableWithTransform
|
||||
|
||||
simpleVariable = '$' name:variableName {
|
||||
return {variable: name}
|
||||
}
|
||||
|
||||
variableWithoutPlaceholder = '${' name:variableName '}' {
|
||||
return {variable: name}
|
||||
}
|
||||
|
||||
variableWithPlaceholder = '${' name:variableName ':' content:innerBodyContent '}' {
|
||||
return {variable: name, content: content}
|
||||
}
|
||||
|
||||
variableWithTransform = '${' name:variableName substitution:transform '}' {
|
||||
return {variable: name, substitution: substitution}
|
||||
}
|
||||
|
||||
variableWithSimpleTransform = '${' name:variableName ':/' substitutionFlag:substitutionFlag '}' {
|
||||
return {variable: name, substitution: {flag: substitutionFlag}}
|
||||
}
|
||||
|
||||
variableName = first:[a-zA-Z_] rest:[a-zA-Z_0-9]* {
|
||||
return first + rest.join('')
|
||||
}
|
||||
|
||||
substitutionFlag = chars:[a-z]+ {
|
||||
return chars.join('')
|
||||
}
|
||||
|
||||
int = [0-9]+
|
||||
|
||||
escaped = '\\' char:. {
|
||||
switch (char) {
|
||||
case '$':
|
||||
case '\\':
|
||||
case ':':
|
||||
case '\x7D': // back brace; PEGjs would treat it as the JS scope end though
|
||||
return char
|
||||
default:
|
||||
return '\\' + char
|
||||
}
|
||||
}
|
||||
|
||||
choiceEscaped = '\\' char:. {
|
||||
switch (char) {
|
||||
case '$':
|
||||
case '\\':
|
||||
case '\x7D':
|
||||
case '|':
|
||||
case ',':
|
||||
return char
|
||||
default:
|
||||
return '\\' + char
|
||||
}
|
||||
}
|
||||
|
||||
flags = flags:[a-z]* {
|
||||
return flags.join('')
|
||||
}
|
||||
|
||||
text = text:(escaped / !tabstop !variable !choice char:. { return char })+ {
|
||||
return text.join('')
|
||||
}
|
||||
|
||||
nonCloseBraceText = text:(escaped / !tabstop !variable !choice char:[^}] { return char })+ {
|
||||
return text.join('')
|
||||
}
|
||||
|
||||
// Two kinds of format string conditional syntax: the `${` flavor and the `(?`
|
||||
// flavor.
|
||||
//
|
||||
// VSCode supports only the `${` flavor. It's easier to parse because the
|
||||
// if-result and else-result can only be plain text, as per the specification.
|
||||
//
|
||||
// TextMate supports both. `(?` is more powerful, but also harder to parse,
|
||||
// because it can contain special flags and regex backreferences.
|
||||
|
||||
// For the first part of a two-part if-else. Runs until the `:` delimiter.
|
||||
ifText = text:(escaped / char:[^:] { return char })+ {
|
||||
return text.join('')
|
||||
}
|
||||
|
||||
// For either the second part of a two-part if-else OR the sole part of a
|
||||
// one-part if/else. Runs until the `}` that ends the expression.
|
||||
ifElseText = text:(escaped / char:[^}] { return char })+ {
|
||||
return text.join('')
|
||||
}
|
||||
|
||||
ifTextAlt = text:(formatEscape / format / escaped / char:[^:] { return char })+ {
|
||||
return coalesce(text);
|
||||
}
|
||||
|
||||
elseTextAlt = text:(formatEscape / format / escaped / char:[^)] { return char })+ {
|
||||
return coalesce(text);
|
||||
}
|
496
packages/snippets/lib/snippet-expansion.js
Normal file
496
packages/snippets/lib/snippet-expansion.js
Normal file
@ -0,0 +1,496 @@
|
||||
const {CompositeDisposable, Range, Point} = require('atom')
|
||||
|
||||
module.exports = class SnippetExpansion {
|
||||
constructor (snippet, editor, cursor, snippets, {method} = {}) {
|
||||
this.settingTabStop = false
|
||||
this.isIgnoringBufferChanges = false
|
||||
this.onUndoOrRedo = this.onUndoOrRedo.bind(this)
|
||||
this.snippet = snippet
|
||||
this.editor = editor
|
||||
this.cursor = cursor
|
||||
this.snippets = snippets
|
||||
this.subscriptions = new CompositeDisposable
|
||||
this.selections = [this.cursor.selection]
|
||||
|
||||
// Method refers to how the snippet was invoked; known values are `prefix`
|
||||
// or `command`. If neither is present, then snippet was inserted
|
||||
// programmatically.
|
||||
this.method = method
|
||||
|
||||
// Holds the `Insertion` instance corresponding to each tab stop marker. We
|
||||
// don't use the tab stop's own numbering here; we renumber them
|
||||
// consecutively starting at 0 in the order in which they should be
|
||||
// visited. So `$1` (if present) will always be at index `0`, and `$0` (if
|
||||
// present) will always be the last index.
|
||||
this.insertionsByIndex = []
|
||||
|
||||
// Each insertion has a corresponding marker. We keep them in a map so we
|
||||
// can easily reassociate an insertion with its new marker when we destroy
|
||||
// its old one.
|
||||
this.markersForInsertions = new Map()
|
||||
|
||||
this.resolutionsForVariables = new Map()
|
||||
this.markersForVariables = new Map()
|
||||
|
||||
// The index of the active tab stop.
|
||||
this.tabStopIndex = null
|
||||
|
||||
// If, say, tab stop 4's placeholder references tab stop 2, then tab stop
|
||||
// 4's insertion goes into this map as a "related" insertion to tab stop 2.
|
||||
// We need to keep track of this because tab stop 4's marker will need to
|
||||
// be replaced while 2 is the active index.
|
||||
this.relatedInsertionsByIndex = new Map()
|
||||
|
||||
const startPosition = this.cursor.selection.getBufferRange().start
|
||||
let {body, tabStopList} = this.snippet
|
||||
let tabStops = tabStopList.toArray()
|
||||
|
||||
let indent = this.editor.lineTextForBufferRow(startPosition.row).match(/^\s*/)[0]
|
||||
if (this.snippet.lineCount > 1 && indent) {
|
||||
// Add proper leading indentation to the snippet
|
||||
body = body.replace(/\n/g, `\n${indent}`)
|
||||
|
||||
tabStops = tabStops.map(tabStop => tabStop.copyWithIndent(indent))
|
||||
}
|
||||
|
||||
this.ignoringBufferChanges(() => {
|
||||
this.editor.transact(() => {
|
||||
// Determine what each variable reference will be replaced by
|
||||
// _before_ we make any changes to the state of the editor. This
|
||||
// affects $TM_SELECTED_TEXT, $TM_CURRENT_WORD, and others.
|
||||
this.resolveVariables(startPosition)
|
||||
// Insert the snippet body at the cursor.
|
||||
const newRange = this.cursor.selection.insertText(body, {autoIndent: false})
|
||||
// Mark the range we just inserted. Once we interpolate variables and
|
||||
// apply transformations, the range may grow, and we need to keep
|
||||
// track of that so we can normalize tabs later on.
|
||||
const newRangeMarker = this.getMarkerLayer(this.editor).markBufferRange(newRange, {exclusive: false})
|
||||
|
||||
if (this.snippet.tabStopList.length > 0) {
|
||||
// Listen for cursor changes so we can decide whether to keep the
|
||||
// snippet active or terminate it.
|
||||
this.subscriptions.add(
|
||||
this.cursor.onDidChangePosition(event => this.cursorMoved(event)),
|
||||
this.cursor.onDidDestroy(() => this.cursorDestroyed())
|
||||
)
|
||||
// First we'll add display markers for tab stops and variables.
|
||||
// Both need these areas to be marked before any expansion happens
|
||||
// so that they don't lose track of where their slots are.
|
||||
this.placeTabStopMarkers(startPosition, tabStops)
|
||||
this.markVariables(startPosition)
|
||||
|
||||
// Now we'll expand variables. All markers in the previous step
|
||||
// were defined with `exclusive: false`, so any that are affected
|
||||
// by variable expansion will grow if necessary.
|
||||
this.expandVariables(startPosition)
|
||||
|
||||
// Now we'll make the first tab stop active and apply snippet
|
||||
// transformations for the first time. As part of this process,
|
||||
// most markers will be converted to `exclusive: true` and adjusted
|
||||
// as necessary as the user tabs through the snippet.
|
||||
this.setTabStopIndex(0)
|
||||
this.applyAllTransformations()
|
||||
|
||||
this.snippets.addExpansion(this.editor, this)
|
||||
} else {
|
||||
// No tab stops, so we're free to mark and expand variables without
|
||||
// worrying about the delicate order of operations.
|
||||
this.markVariables(startPosition)
|
||||
this.expandVariables(startPosition)
|
||||
}
|
||||
|
||||
// Snippet bodies are written generically and don't know anything
|
||||
// about the user's indentation settings. So we adjust them after
|
||||
// expansion.
|
||||
this.editor.normalizeTabsInBufferRange(newRangeMarker.getBufferRange())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Set a flag on undo or redo so that we know not to re-apply transforms.
|
||||
// They're already accounted for in the history.
|
||||
onUndoOrRedo (isUndo) {
|
||||
this.isUndoingOrRedoing = true
|
||||
}
|
||||
|
||||
cursorMoved ({oldBufferPosition, newBufferPosition, textChanged}) {
|
||||
if (this.settingTabStop || textChanged) { return }
|
||||
const insertionAtCursor = this.insertionsByIndex[this.tabStopIndex].find(insertion => {
|
||||
let marker = this.markersForInsertions.get(insertion)
|
||||
return marker.getBufferRange().containsPoint(newBufferPosition)
|
||||
})
|
||||
|
||||
if (insertionAtCursor && !insertionAtCursor.isTransformation()) { return }
|
||||
|
||||
this.destroy()
|
||||
}
|
||||
|
||||
cursorDestroyed () {
|
||||
// The only time a cursor can be destroyed without it ending the snippet is
|
||||
// if we move from a mirrored tab stop (i.e., multiple cursors) to a
|
||||
// single-cursor tab stop.
|
||||
if (!this.settingTabStop) { this.destroy() }
|
||||
}
|
||||
|
||||
textChanged (event) {
|
||||
if (this.isIgnoringBufferChanges) { return }
|
||||
|
||||
// Don't try to alter the buffer if all we're doing is restoring a snapshot
|
||||
// from history.
|
||||
if (this.isUndoingOrRedoing) {
|
||||
this.isUndoingOrRedoing = false
|
||||
return
|
||||
}
|
||||
|
||||
this.applyTransformations(this.tabStopIndex)
|
||||
}
|
||||
|
||||
ignoringBufferChanges (callback) {
|
||||
const wasIgnoringBufferChanges = this.isIgnoringBufferChanges
|
||||
this.isIgnoringBufferChanges = true
|
||||
callback()
|
||||
this.isIgnoringBufferChanges = wasIgnoringBufferChanges
|
||||
}
|
||||
|
||||
applyAllTransformations () {
|
||||
this.editor.transact(() => {
|
||||
this.insertionsByIndex.forEach((insertion, index) =>
|
||||
this.applyTransformations(index))
|
||||
})
|
||||
}
|
||||
|
||||
applyTransformations (tabStopIndex) {
|
||||
const insertions = [...this.insertionsByIndex[tabStopIndex]]
|
||||
if (insertions.length === 0) { return }
|
||||
|
||||
const primaryInsertion = insertions.shift()
|
||||
const primaryRange = this.markersForInsertions.get(primaryInsertion).getBufferRange()
|
||||
const inputText = this.editor.getTextInBufferRange(primaryRange)
|
||||
|
||||
this.ignoringBufferChanges(() => {
|
||||
for (const [index, insertion] of insertions.entries()) {
|
||||
// Don't transform mirrored tab stops. They have their own cursors, so
|
||||
// mirroring happens automatically.
|
||||
if (!insertion.isTransformation()) { continue }
|
||||
|
||||
var marker = this.markersForInsertions.get(insertion)
|
||||
var range = marker.getBufferRange()
|
||||
|
||||
var outputText = insertion.transform(inputText)
|
||||
this.editor.transact(() => this.editor.setTextInBufferRange(range, outputText))
|
||||
|
||||
// Manually adjust the marker's range rather than rely on its internal
|
||||
// heuristics. (We don't have to worry about whether it's been
|
||||
// invalidated because setting its buffer range implicitly marks it as
|
||||
// valid again.)
|
||||
const newRange = new Range(
|
||||
range.start,
|
||||
range.start.traverse(new Point(0, outputText.length))
|
||||
)
|
||||
marker.setBufferRange(newRange)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
resolveVariables (startPosition) {
|
||||
let params = {
|
||||
editor: this.editor,
|
||||
cursor: this.cursor,
|
||||
selectionRange: this.cursor.selection.getBufferRange(),
|
||||
method: this.method
|
||||
}
|
||||
|
||||
for (const variable of this.snippet.variables) {
|
||||
let resolution = variable.resolve(params)
|
||||
this.resolutionsForVariables.set(variable, resolution)
|
||||
}
|
||||
}
|
||||
|
||||
markVariables (startPosition) {
|
||||
// We make two passes here. On the first pass, we create markers for each
|
||||
// point where a variable will be inserted. On the second pass, we use each
|
||||
// marker to insert the resolved variable value.
|
||||
//
|
||||
// Those points will move around as we insert text into them, so the
|
||||
// markers are crucial for ensuring we adapt to those changes.
|
||||
for (const variable of this.snippet.variables) {
|
||||
const {point} = variable
|
||||
const marker = this.getMarkerLayer(this.editor).markBufferRange([
|
||||
startPosition.traverse(point),
|
||||
startPosition.traverse(point)
|
||||
], {exclusive: false})
|
||||
this.markersForVariables.set(variable, marker)
|
||||
}
|
||||
}
|
||||
|
||||
expandVariables (startPosition) {
|
||||
this.editor.transact(() => {
|
||||
for (const variable of this.snippet.variables) {
|
||||
let marker = this.markersForVariables.get(variable)
|
||||
let resolution = this.resolutionsForVariables.get(variable)
|
||||
let range = marker.getBufferRange()
|
||||
this.editor.setTextInBufferRange(range, resolution)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
placeTabStopMarkers (startPosition, tabStops) {
|
||||
// Tab stops within a snippet refer to one another by their external index
|
||||
// (1 for $1, 3 for $3, etc.). We respect the order of these tab stops, but
|
||||
// we renumber them starting at 0 and using consecutive numbers.
|
||||
//
|
||||
// Luckily, we don't need to convert between the two numbering systems very
|
||||
// often. But we do have to build a map from external index to our internal
|
||||
// index. We do this in a separate loop so that the table is complete
|
||||
// before we need to consult it in the following loop.
|
||||
const indexTable = {}
|
||||
for (let [index, tabStop] of tabStops.entries()) {
|
||||
indexTable[tabStop.index] = index
|
||||
}
|
||||
|
||||
for (let [index, tabStop] of tabStops.entries()) {
|
||||
const {insertions} = tabStop
|
||||
|
||||
if (!tabStop.isValid()) { continue }
|
||||
|
||||
for (const insertion of insertions) {
|
||||
const {range} = insertion
|
||||
const {start, end} = range
|
||||
let references = null
|
||||
if (insertion.references) {
|
||||
references = insertion.references.map(external => indexTable[external])
|
||||
}
|
||||
// This is our initial pass at marking tab stop regions. In a minute,
|
||||
// once the first tab stop is made active, we will make some of these
|
||||
// markers exclusive and some inclusive. But right now we need them all
|
||||
// to be inclusive, because we want them all to react when we resolve
|
||||
// snippet variables, and grow if they need to.
|
||||
const marker = this.getMarkerLayer(this.editor).markBufferRange([
|
||||
startPosition.traverse(start),
|
||||
startPosition.traverse(end)
|
||||
], {exclusive: false})
|
||||
// Now that we've created these markers, we need to store them in a
|
||||
// data structure because they'll need to be deleted and re-created
|
||||
// when their exclusivity changes.
|
||||
this.markersForInsertions.set(insertion, marker)
|
||||
|
||||
if (references) {
|
||||
// The insertion at tab stop `index` (internal numbering) is related
|
||||
// to, and affected by, all the tab stops mentioned in `references`
|
||||
// (internal numbering). We need to make sure we're included in these
|
||||
// other tab stops' exclusivity changes.
|
||||
for (let ref of references) {
|
||||
let relatedInsertions = this.relatedInsertionsByIndex.get(ref) || []
|
||||
relatedInsertions.push(insertion)
|
||||
this.relatedInsertionsByIndex.set(ref, relatedInsertions)
|
||||
}
|
||||
}
|
||||
}
|
||||
this.insertionsByIndex[index] = insertions
|
||||
}
|
||||
}
|
||||
|
||||
// When two insertion markers are directly adjacent to one another, and the
|
||||
// cursor is placed right at the border between them, the marker that should
|
||||
// "claim" the newly typed content will vary based on context.
|
||||
//
|
||||
// All else being equal, that content should get added to the marker (if any)
|
||||
// whose tab stop is active, or else the marker whose tab stop's placeholder
|
||||
// references an active tab stop. To use the terminology of Atom's
|
||||
// `DisplayMarker`, all markers related to the active tab stop should be
|
||||
// "inclusive," and all others should be "exclusive."
|
||||
//
|
||||
// Exclusivity cannot be changed after a marker is created. So we need to
|
||||
// revisit the markers whenever the active tab stop changes, figure out which
|
||||
// ones need to be touched, and replace them with markers that have the
|
||||
// settings we need.
|
||||
//
|
||||
adjustTabStopMarkers (oldIndex, newIndex) {
|
||||
// All the insertions belonging to the newly active tab stop (and all
|
||||
// insertions whose placeholders reference the newly active tab stop)
|
||||
// should become inclusive.
|
||||
const insertionsToMakeInclusive = [
|
||||
...this.insertionsByIndex[newIndex],
|
||||
...(this.relatedInsertionsByIndex.get(newIndex) || [])
|
||||
]
|
||||
|
||||
// All insertions that are _not_ related to the newly active tab stop
|
||||
// should become exclusive if they aren't already.
|
||||
let insertionsToMakeExclusive
|
||||
if (oldIndex === null) {
|
||||
// This is the first index to be made active. Since all insertion markers
|
||||
// were initially created to be inclusive, we need to adjust _all_
|
||||
// insertion markers that are not related to the new tab stop.
|
||||
let allInsertions = this.insertionsByIndex.reduce((set, ins) => {
|
||||
set.push(...ins)
|
||||
return set
|
||||
}, [])
|
||||
insertionsToMakeExclusive = allInsertions.filter(ins => {
|
||||
return !insertionsToMakeInclusive.includes(ins)
|
||||
})
|
||||
} else {
|
||||
// We are moving from one tab stop to another, so we only need to touch
|
||||
// the markers related to the tab stop we're departing.
|
||||
insertionsToMakeExclusive = [
|
||||
...this.insertionsByIndex[oldIndex],
|
||||
...(this.relatedInsertionsByIndex.get(oldIndex) || [])
|
||||
]
|
||||
}
|
||||
|
||||
for (let insertion of insertionsToMakeExclusive) {
|
||||
this.replaceMarkerForInsertion(insertion, {exclusive: true})
|
||||
}
|
||||
|
||||
for (let insertion of insertionsToMakeInclusive) {
|
||||
this.replaceMarkerForInsertion(insertion, {exclusive: false})
|
||||
}
|
||||
}
|
||||
|
||||
replaceMarkerForInsertion (insertion, settings) {
|
||||
const marker = this.markersForInsertions.get(insertion)
|
||||
|
||||
// If the marker is invalid or destroyed, return it as-is. Other methods
|
||||
// need to know if a marker has been invalidated or destroyed, and we have
|
||||
// no need to change the settings on such markers anyway.
|
||||
if (!marker.isValid() || marker.isDestroyed()) {
|
||||
return marker
|
||||
}
|
||||
|
||||
// Otherwise, create a new marker with an identical range and the specified
|
||||
// settings.
|
||||
const range = marker.getBufferRange()
|
||||
const replacement = this.getMarkerLayer(this.editor).markBufferRange(range, settings)
|
||||
|
||||
marker.destroy()
|
||||
this.markersForInsertions.set(insertion, replacement)
|
||||
return replacement
|
||||
}
|
||||
|
||||
goToNextTabStop () {
|
||||
const nextIndex = this.tabStopIndex + 1
|
||||
if (nextIndex < this.insertionsByIndex.length) {
|
||||
if (this.setTabStopIndex(nextIndex)) {
|
||||
return true
|
||||
} else {
|
||||
return this.goToNextTabStop()
|
||||
}
|
||||
} else {
|
||||
// The user has tabbed past the last tab stop. If the last tab stop is a
|
||||
// $0, we shouldn't move the cursor any further.
|
||||
if (this.snippet.tabStopList.hasEndStop) {
|
||||
this.destroy()
|
||||
return false
|
||||
} else {
|
||||
const succeeded = this.goToEndOfLastTabStop()
|
||||
this.destroy()
|
||||
return succeeded
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
goToPreviousTabStop () {
|
||||
if (this.tabStopIndex > 0) { this.setTabStopIndex(this.tabStopIndex - 1) }
|
||||
}
|
||||
|
||||
setTabStopIndex (newIndex) {
|
||||
const oldIndex = this.tabStopIndex
|
||||
this.tabStopIndex = newIndex
|
||||
// Set a flag before moving any selections so that our change handlers know
|
||||
// that the movements were initiated by us.
|
||||
this.settingTabStop = true
|
||||
// Keep track of whether we placed any selections or cursors.
|
||||
let markerSelected = false
|
||||
|
||||
const insertions = this.insertionsByIndex[this.tabStopIndex]
|
||||
if (insertions.length === 0) { return false }
|
||||
|
||||
const ranges = []
|
||||
this.hasTransforms = false
|
||||
|
||||
// Go through the active tab stop's markers to figure out where to place
|
||||
// cursors and/or selections.
|
||||
for (const insertion of insertions) {
|
||||
const marker = this.markersForInsertions.get(insertion)
|
||||
if (marker.isDestroyed()) { continue }
|
||||
if (!marker.isValid()) { continue }
|
||||
if (insertion.isTransformation()) {
|
||||
// Set a flag for later, but skip transformation insertions because
|
||||
// they don't get their own cursors.
|
||||
this.hasTransforms = true
|
||||
continue
|
||||
}
|
||||
ranges.push(marker.getBufferRange())
|
||||
}
|
||||
|
||||
if (ranges.length > 0) {
|
||||
// We have new selections to apply. Reuse existing selections if
|
||||
// possible, destroying the unused ones if we already have too many.
|
||||
for (const selection of this.selections.slice(ranges.length)) { selection.destroy() }
|
||||
this.selections = this.selections.slice(0, ranges.length)
|
||||
for (let i = 0; i < ranges.length; i++) {
|
||||
const range = ranges[i]
|
||||
if (this.selections[i]) {
|
||||
this.selections[i].setBufferRange(range)
|
||||
} else {
|
||||
const newSelection = this.editor.addSelectionForBufferRange(range)
|
||||
this.subscriptions.add(newSelection.cursor.onDidChangePosition(event => this.cursorMoved(event)))
|
||||
this.subscriptions.add(newSelection.cursor.onDidDestroy(() => this.cursorDestroyed()))
|
||||
this.selections.push(newSelection)
|
||||
}
|
||||
}
|
||||
// We placed at least one selection, so this tab stop was successfully
|
||||
// set.
|
||||
markerSelected = true
|
||||
}
|
||||
|
||||
this.settingTabStop = false
|
||||
// If this snippet has at least one transform, we need to observe changes
|
||||
// made to the editor so that we can update the transformed tab stops.
|
||||
if (this.hasTransforms) {
|
||||
this.snippets.observeEditor(this.editor)
|
||||
} else {
|
||||
this.snippets.stopObservingEditor(this.editor)
|
||||
}
|
||||
|
||||
this.adjustTabStopMarkers(oldIndex, newIndex)
|
||||
|
||||
return markerSelected
|
||||
}
|
||||
|
||||
goToEndOfLastTabStop () {
|
||||
const size = this.insertionsByIndex.length
|
||||
if (size === 0) { return }
|
||||
const insertions = this.insertionsByIndex[size - 1]
|
||||
if (insertions.length === 0) { return }
|
||||
const lastMarker = this.markersForInsertions.get(insertions[insertions.length - 1])
|
||||
|
||||
if (lastMarker.isDestroyed()) {
|
||||
return false
|
||||
} else {
|
||||
this.editor.setCursorBufferPosition(lastMarker.getEndBufferPosition())
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
destroy () {
|
||||
this.subscriptions.dispose()
|
||||
this.getMarkerLayer(this.editor).clear()
|
||||
this.insertionsByIndex = []
|
||||
this.relatedInsertionsByIndex.clear()
|
||||
this.markersForInsertions.clear()
|
||||
this.resolutionsForVariables.clear()
|
||||
this.markersForVariables.clear()
|
||||
|
||||
this.snippets.stopObservingEditor(this.editor)
|
||||
this.snippets.clearExpansions(this.editor)
|
||||
}
|
||||
|
||||
getMarkerLayer () {
|
||||
return this.snippets.findOrCreateMarkerLayer(this.editor)
|
||||
}
|
||||
|
||||
restore (editor) {
|
||||
this.editor = editor
|
||||
this.snippets.addExpansion(this.editor, this)
|
||||
}
|
||||
}
|
27
packages/snippets/lib/snippet-history-provider.js
Normal file
27
packages/snippets/lib/snippet-history-provider.js
Normal file
@ -0,0 +1,27 @@
|
||||
function wrap (manager, callbacks) {
|
||||
let klass = new SnippetHistoryProvider(manager)
|
||||
return new Proxy(manager, {
|
||||
get (target, name) {
|
||||
if (name in callbacks) {
|
||||
callbacks[name]()
|
||||
}
|
||||
return name in klass ? klass[name] : target[name]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
class SnippetHistoryProvider {
|
||||
constructor (manager) {
|
||||
this.manager = manager
|
||||
}
|
||||
|
||||
undo (...args) {
|
||||
return this.manager.undo(...args)
|
||||
}
|
||||
|
||||
redo (...args) {
|
||||
return this.manager.redo(...args)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = wrap
|
109
packages/snippets/lib/snippet.js
Normal file
109
packages/snippets/lib/snippet.js
Normal file
@ -0,0 +1,109 @@
|
||||
const {Point, Range} = require('atom')
|
||||
const TabStopList = require('./tab-stop-list')
|
||||
const Variable = require('./variable')
|
||||
|
||||
function tabStopsReferencedWithinTabStopContent (segment) {
|
||||
const results = []
|
||||
for (const item of segment) {
|
||||
if (item.index) {
|
||||
results.push(item.index, ...tabStopsReferencedWithinTabStopContent(item.content))
|
||||
}
|
||||
}
|
||||
return new Set(results)
|
||||
}
|
||||
|
||||
module.exports = class Snippet {
|
||||
constructor (attrs) {
|
||||
let {
|
||||
id,
|
||||
bodyText,
|
||||
bodyTree,
|
||||
command,
|
||||
description,
|
||||
descriptionMoreURL,
|
||||
leftLabel,
|
||||
leftLabelHTML,
|
||||
name,
|
||||
prefix,
|
||||
packageName,
|
||||
rightLabelHTML,
|
||||
selector
|
||||
} = attrs
|
||||
|
||||
this.id = id
|
||||
this.name = name
|
||||
this.prefix = prefix
|
||||
this.command = command
|
||||
this.packageName = packageName
|
||||
this.bodyText = bodyText
|
||||
this.description = description
|
||||
this.descriptionMoreURL = descriptionMoreURL
|
||||
this.rightLabelHTML = rightLabelHTML
|
||||
this.leftLabel = leftLabel
|
||||
this.leftLabelHTML = leftLabelHTML
|
||||
this.selector = selector
|
||||
|
||||
this.variables = []
|
||||
this.tabStopList = new TabStopList(this)
|
||||
this.body = this.extractTokens(bodyTree)
|
||||
|
||||
if (packageName && command) {
|
||||
this.commandName = `${packageName}:${command}`
|
||||
}
|
||||
}
|
||||
|
||||
extractTokens (bodyTree) {
|
||||
const bodyText = []
|
||||
let row = 0, column = 0
|
||||
|
||||
let extract = bodyTree => {
|
||||
for (let segment of bodyTree) {
|
||||
if (segment.index != null) {
|
||||
// Tabstop.
|
||||
let {index, content, substitution} = segment
|
||||
// Ensure tabstop `$0` is always last.
|
||||
if (index === 0) { index = Infinity }
|
||||
|
||||
const start = [row, column]
|
||||
extract(content)
|
||||
|
||||
const referencedTabStops = tabStopsReferencedWithinTabStopContent(content)
|
||||
|
||||
const range = new Range(start, [row, column])
|
||||
|
||||
const tabStop = this.tabStopList.findOrCreate({
|
||||
index, snippet: this
|
||||
})
|
||||
|
||||
tabStop.addInsertion({
|
||||
range,
|
||||
substitution,
|
||||
references: [...referencedTabStops]
|
||||
})
|
||||
} else if (segment.variable != null) {
|
||||
// Variable.
|
||||
let point = new Point(row, column)
|
||||
this.variables.push(
|
||||
new Variable({...segment, point, snippet: this})
|
||||
)
|
||||
} else if (typeof segment === 'string') {
|
||||
bodyText.push(segment)
|
||||
let segmentLines = segment.split('\n')
|
||||
column += segmentLines.shift().length
|
||||
let nextLine
|
||||
while ((nextLine = segmentLines.shift()) != null) {
|
||||
row += 1
|
||||
column = nextLine.length
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extract(bodyTree)
|
||||
this.lineCount = row + 1
|
||||
this.insertions = this.tabStopList.getInsertions()
|
||||
|
||||
return bodyText.join('')
|
||||
}
|
||||
|
||||
}
|
84
packages/snippets/lib/snippets-available.js
Normal file
84
packages/snippets/lib/snippets-available.js
Normal file
@ -0,0 +1,84 @@
|
||||
/** @babel */
|
||||
|
||||
import _ from 'underscore-plus'
|
||||
import SelectListView from 'atom-select-list'
|
||||
|
||||
export default class SnippetsAvailable {
|
||||
constructor (snippets) {
|
||||
this.panel = null
|
||||
this.snippets = snippets
|
||||
this.selectListView = new SelectListView({
|
||||
items: [],
|
||||
filterKeyForItem: (snippet) => snippet.searchText,
|
||||
elementForItem: (snippet) => {
|
||||
const li = document.createElement('li')
|
||||
li.classList.add('two-lines')
|
||||
|
||||
const primaryLine = document.createElement('div')
|
||||
primaryLine.classList.add('primary-line')
|
||||
primaryLine.textContent = snippet.prefix
|
||||
li.appendChild(primaryLine)
|
||||
|
||||
const secondaryLine = document.createElement('div')
|
||||
secondaryLine.classList.add('secondary-line')
|
||||
secondaryLine.textContent = snippet.name
|
||||
li.appendChild(secondaryLine)
|
||||
|
||||
return li
|
||||
},
|
||||
didConfirmSelection: (snippet) => {
|
||||
for (const cursor of this.editor.getCursors()) {
|
||||
this.snippets.insert(snippet.bodyText, this.editor, cursor)
|
||||
}
|
||||
this.cancel()
|
||||
},
|
||||
didConfirmEmptySelection: () => {
|
||||
this.cancel()
|
||||
},
|
||||
didCancelSelection: () => {
|
||||
this.cancel()
|
||||
}
|
||||
})
|
||||
this.selectListView.element.classList.add('available-snippets')
|
||||
this.element = this.selectListView.element
|
||||
}
|
||||
|
||||
async toggle (editor) {
|
||||
this.editor = editor
|
||||
if (this.panel != null) {
|
||||
this.cancel()
|
||||
} else {
|
||||
this.selectListView.reset()
|
||||
await this.populate()
|
||||
this.attach()
|
||||
}
|
||||
}
|
||||
|
||||
cancel () {
|
||||
this.editor = null
|
||||
|
||||
if (this.panel != null) {
|
||||
this.panel.destroy()
|
||||
this.panel = null
|
||||
}
|
||||
|
||||
if (this.previouslyFocusedElement) {
|
||||
this.previouslyFocusedElement.focus()
|
||||
this.previouslyFocusedElement = null
|
||||
}
|
||||
}
|
||||
|
||||
populate () {
|
||||
const snippets = Object.values(this.snippets.getSnippets(this.editor))
|
||||
for (let snippet of snippets) {
|
||||
snippet.searchText = _.compact([snippet.prefix, snippet.name]).join(' ')
|
||||
}
|
||||
return this.selectListView.update({items: snippets})
|
||||
}
|
||||
|
||||
attach () {
|
||||
this.previouslyFocusedElement = document.activeElement
|
||||
this.panel = atom.workspace.addModalPanel({item: this})
|
||||
this.selectListView.focus()
|
||||
}
|
||||
}
|
57
packages/snippets/lib/snippets.cson
Normal file
57
packages/snippets/lib/snippets.cson
Normal file
@ -0,0 +1,57 @@
|
||||
'.source.json':
|
||||
'Atom Snippet':
|
||||
prefix: 'snip'
|
||||
body: """
|
||||
{
|
||||
"${1:.source.js}": {
|
||||
"${2:Snippet Name}": {
|
||||
"prefix": "${3:Snippet Trigger}",
|
||||
"body": "${4:Hello World!}"
|
||||
}
|
||||
}
|
||||
}$5
|
||||
"""
|
||||
|
||||
'Atom Snippet With No Selector':
|
||||
prefix: 'snipns'
|
||||
body: """
|
||||
"${1:Snippet Name}": {
|
||||
"prefix": "${2:Snippet Trigger}",
|
||||
"body": "${3:Hello World!}"
|
||||
}$4
|
||||
"""
|
||||
|
||||
'Atom Keymap':
|
||||
prefix: 'key'
|
||||
body: """
|
||||
{
|
||||
"${1:body}": {
|
||||
"${2:cmd}-${3:i}": "${4:namespace}:${5:event}"
|
||||
}
|
||||
}$6
|
||||
"""
|
||||
|
||||
'.source.coffee':
|
||||
'Atom Snippet':
|
||||
prefix: 'snip'
|
||||
body: """
|
||||
'${1:.source.js}':
|
||||
'${2:Snippet Name}':
|
||||
'prefix': '${3:Snippet Trigger}'
|
||||
'body': '${4:Hello World!}'$5
|
||||
"""
|
||||
|
||||
'Atom Snippet With No Selector':
|
||||
prefix: 'snipns'
|
||||
body: """
|
||||
'${1:Snippet Name}':
|
||||
'prefix': '${2:Snippet Trigger}'
|
||||
'body': '${3:Hello World!}'$4
|
||||
"""
|
||||
|
||||
'Atom Keymap':
|
||||
prefix: 'key'
|
||||
body: """
|
||||
'${1:body}':
|
||||
'${2:cmd}-${3:i}': '${4:namespace}:${5:event}'$6
|
||||
"""
|
936
packages/snippets/lib/snippets.js
Normal file
936
packages/snippets/lib/snippets.js
Normal file
@ -0,0 +1,936 @@
|
||||
const path = require('path')
|
||||
const {Emitter, Disposable, CompositeDisposable, File} = require('atom')
|
||||
const _ = require('underscore-plus')
|
||||
const async = require('async')
|
||||
const CSON = require('season')
|
||||
const fs = require('fs')
|
||||
const ScopedPropertyStore = require('scoped-property-store')
|
||||
|
||||
const Snippet = require('./snippet')
|
||||
const SnippetExpansion = require('./snippet-expansion')
|
||||
const EditorStore = require('./editor-store')
|
||||
const {getPackageRoot} = require('./helpers')
|
||||
|
||||
// TODO: Not sure about validity of numbers in here, but might as well be
|
||||
// permissive.
|
||||
const COMMAND_NAME_PATTERN = /^[a-z\d][a-z\d\-]*[a-z\d]$/
|
||||
function isValidCommandName (commandName) {
|
||||
return COMMAND_NAME_PATTERN.test(commandName)
|
||||
}
|
||||
|
||||
function showCommandNameConflictNotification (name, commandName, packageName, snippetsPath) {
|
||||
let remedy
|
||||
if (packageName === 'builtin') {
|
||||
// If somehow this happens with a builtin snippet, something crazy is
|
||||
// happening. But we shouldn't show a notification because there's no
|
||||
// action for the user to take. Just fail silently.
|
||||
return
|
||||
}
|
||||
if (packageName === 'snippets') {
|
||||
let extension = snippetsPath.substring(snippetsPath.length - 4)
|
||||
remedy = `Edit your \`snippets.${extension}\` file to resolve this conflict.`
|
||||
} else {
|
||||
remedy = `Contact the maintainer of \`${packageName}\` so they can resolve this conflict.`
|
||||
}
|
||||
const message = `Cannot register command \`${commandName}\` for snippet “${name}” because that command name already exists.\n\n${remedy}`
|
||||
atom.notifications.addError(
|
||||
`Snippets conflict`,
|
||||
{
|
||||
description: message,
|
||||
dismissable: true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function showInvalidCommandNameNotification (name, commandName) {
|
||||
const message = `Cannot register \`${commandName}\` for snippet “${name}” because the command name isn’t valid. Command names must be all lowercase and use hyphens between words instead of spaces.`
|
||||
atom.notifications.addError(
|
||||
`Snippets error`,
|
||||
{
|
||||
description: message,
|
||||
dismissable: true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// When we first run, checking `atom.commands.registeredCommands` is a good way
|
||||
// of checking whether a command of a certain name already exists. But if we
|
||||
// register a command and then unregister it (e.g., upon later disabling of a
|
||||
// package's snippets), the relevant key won't get deleted from
|
||||
// `registeredCommands`. So if the user re-enables the snippets, we'll
|
||||
// incorrectly think that the command already exists.
|
||||
//
|
||||
// Hence, after the first check, we have to keep track ourselves. At least this
|
||||
// gives us a place to keep track of individual command disposables.
|
||||
//
|
||||
const CommandMonitor = {
|
||||
map: new Map,
|
||||
disposables: new Map,
|
||||
compositeDisposable: new CompositeDisposable,
|
||||
exists (commandName) {
|
||||
let {map} = this
|
||||
if (!map.has(commandName)) {
|
||||
// If it's missing altogether from the registry, we haven't asked yet.
|
||||
let value = atom.commands.registeredCommands[commandName]
|
||||
map.set(commandName, value)
|
||||
return value
|
||||
} else {
|
||||
return map.get(commandName)
|
||||
}
|
||||
},
|
||||
|
||||
add (commandName, disposable) {
|
||||
this.map.set(commandName, true)
|
||||
this.disposables.set(commandName, disposable)
|
||||
this.compositeDisposable.add(disposable)
|
||||
},
|
||||
|
||||
remove (commandName) {
|
||||
this.map.set(commandName, false)
|
||||
let disposable = this.disposables.get(commandName)
|
||||
if (disposable) { disposable.dispose() }
|
||||
},
|
||||
|
||||
reset () {
|
||||
this.map.clear()
|
||||
this.disposables.clear()
|
||||
this.compositeDisposable.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
// When we load snippets from packages, we're given a bunch of package paths
|
||||
// instead of package names. This lets us match the former to the latter.
|
||||
const PackageNameResolver = {
|
||||
pathsToNames: new Map,
|
||||
setup () {
|
||||
this.pathsToNames.clear()
|
||||
let meta = atom.packages.getLoadedPackages() || []
|
||||
for (let {name, path} of meta) {
|
||||
this.pathsToNames.set(path, name)
|
||||
}
|
||||
if (!this._observing) {
|
||||
atom.packages.onDidLoadPackage(() => this.setup())
|
||||
atom.packages.onDidUnloadPackage(() => this.setup())
|
||||
}
|
||||
this._observing = true
|
||||
},
|
||||
find (filePath) {
|
||||
for (let [packagePath, name] of this.pathsToNames.entries()) {
|
||||
if (filePath.startsWith(`${packagePath}${path.sep}`)) return name
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
activate () {
|
||||
this.loaded = false
|
||||
this.userSnippetsPath = null
|
||||
this.snippetIdCounter = 0
|
||||
this.snippetsByPackage = new Map
|
||||
this.parsedSnippetsById = new Map
|
||||
this.editorMarkerLayers = new WeakMap
|
||||
|
||||
this.scopedPropertyStore = new ScopedPropertyStore
|
||||
// The above ScopedPropertyStore will store the main registry of snippets.
|
||||
// But we need a separate ScopedPropertyStore for the snippets that come
|
||||
// from disabled packages. They're isolated so that they're not considered
|
||||
// as candidates when the user expands a prefix, but we still need the data
|
||||
// around so that the snippets provided by those packages can be shown in
|
||||
// the settings view.
|
||||
this.disabledSnippetsScopedPropertyStore = new ScopedPropertyStore
|
||||
|
||||
this.subscriptions = new CompositeDisposable
|
||||
this.subscriptions.add(atom.workspace.addOpener(uri => {
|
||||
if (uri === 'atom://.pulsar/snippets') {
|
||||
return atom.workspace.openTextFile(this.getUserSnippetsPath())
|
||||
}
|
||||
}))
|
||||
|
||||
PackageNameResolver.setup()
|
||||
|
||||
this.loadAll()
|
||||
this.watchUserSnippets(watchDisposable => {
|
||||
this.subscriptions.add(watchDisposable)
|
||||
})
|
||||
|
||||
this.subscriptions.add(
|
||||
atom.config.onDidChange(
|
||||
'core.packagesWithSnippetsDisabled',
|
||||
({newValue, oldValue}) => {
|
||||
this.handleDisabledPackagesDidChange(newValue, oldValue)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const snippets = this
|
||||
|
||||
this.subscriptions.add(atom.commands.add('atom-text-editor', {
|
||||
'snippets:expand' (event) {
|
||||
const editor = this.getModel()
|
||||
if (snippets.snippetToExpandUnderCursor(editor)) {
|
||||
snippets.clearExpansions(editor)
|
||||
snippets.expandSnippetsUnderCursors(editor)
|
||||
} else {
|
||||
event.abortKeyBinding()
|
||||
}
|
||||
},
|
||||
|
||||
'snippets:next-tab-stop' (event) {
|
||||
const editor = this.getModel()
|
||||
if (!snippets.goToNextTabStop(editor)) { event.abortKeyBinding() }
|
||||
},
|
||||
|
||||
'snippets:previous-tab-stop' (event) {
|
||||
const editor = this.getModel()
|
||||
if (!snippets.goToPreviousTabStop(editor)) { event.abortKeyBinding() }
|
||||
},
|
||||
|
||||
'snippets:available' (event) {
|
||||
const editor = this.getModel()
|
||||
const SnippetsAvailable = require('./snippets-available')
|
||||
if (snippets.availableSnippetsView == null) {
|
||||
snippets.availableSnippetsView = new SnippetsAvailable(snippets)
|
||||
}
|
||||
snippets.availableSnippetsView.toggle(editor)
|
||||
}
|
||||
}))
|
||||
},
|
||||
|
||||
deactivate () {
|
||||
if (this.emitter != null) {
|
||||
this.emitter.dispose()
|
||||
}
|
||||
this.emitter = null
|
||||
this.editorSnippetExpansions = null
|
||||
atom.config.transact(() => this.subscriptions.dispose())
|
||||
CommandMonitor.reset()
|
||||
},
|
||||
|
||||
getUserSnippetsPath () {
|
||||
if (this.userSnippetsPath != null) { return this.userSnippetsPath }
|
||||
|
||||
this.userSnippetsPath = CSON.resolve(path.join(atom.getConfigDirPath(), 'snippets'))
|
||||
if (this.userSnippetsPath == null) { this.userSnippetsPath = path.join(atom.getConfigDirPath(), 'snippets.cson') }
|
||||
return this.userSnippetsPath
|
||||
},
|
||||
|
||||
loadAll () {
|
||||
this.loadBundledSnippets(bundledSnippets => {
|
||||
this.loadPackageSnippets(packageSnippets => {
|
||||
this.loadUserSnippets(userSnippets => {
|
||||
atom.config.transact(() => {
|
||||
for (const [filepath, snippetsBySelector] of Object.entries(bundledSnippets)) {
|
||||
this.add(filepath, snippetsBySelector, 'builtin')
|
||||
}
|
||||
for (const [filepath, snippetsBySelector] of Object.entries(packageSnippets)) {
|
||||
let packageName = PackageNameResolver.find(filepath) || 'snippets'
|
||||
this.add(filepath, snippetsBySelector, packageName)
|
||||
}
|
||||
for (const [filepath, snippetsBySelector] of Object.entries(userSnippets)) {
|
||||
this.add(filepath, snippetsBySelector, 'snippets')
|
||||
}
|
||||
})
|
||||
this.doneLoading()
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
loadBundledSnippets (callback) {
|
||||
const bundledSnippetsPath = CSON.resolve(path.join(getPackageRoot(), 'lib', 'snippets'))
|
||||
this.loadSnippetsFile(bundledSnippetsPath, snippets => {
|
||||
const snippetsByPath = {}
|
||||
snippetsByPath[bundledSnippetsPath] = snippets
|
||||
callback(snippetsByPath)
|
||||
})
|
||||
},
|
||||
|
||||
loadUserSnippets (callback) {
|
||||
const userSnippetsPath = this.getUserSnippetsPath()
|
||||
fs.stat(userSnippetsPath, (error, stat) => {
|
||||
if (stat != null && stat.isFile()) {
|
||||
this.loadSnippetsFile(userSnippetsPath, snippets => {
|
||||
const result = {}
|
||||
result[userSnippetsPath] = snippets
|
||||
callback(result)
|
||||
})
|
||||
} else {
|
||||
callback({})
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
watchUserSnippets (callback) {
|
||||
const userSnippetsPath = this.getUserSnippetsPath()
|
||||
fs.stat(userSnippetsPath, (error, stat) => {
|
||||
if (stat != null && stat.isFile()) {
|
||||
const userSnippetsFileDisposable = new CompositeDisposable()
|
||||
const userSnippetsFile = new File(userSnippetsPath)
|
||||
try {
|
||||
userSnippetsFileDisposable.add(userSnippetsFile.onDidChange(() => this.handleUserSnippetsDidChange()))
|
||||
userSnippetsFileDisposable.add(userSnippetsFile.onDidDelete(() => this.handleUserSnippetsDidChange()))
|
||||
userSnippetsFileDisposable.add(userSnippetsFile.onDidRename(() => this.handleUserSnippetsDidChange()))
|
||||
} catch (e) {
|
||||
const message = `\
|
||||
Unable to watch path: \`snippets.cson\`. Make sure you have permissions
|
||||
to the \`~/.pulsar\` directory and \`${userSnippetsPath}\`.
|
||||
|
||||
On linux there are currently problems with watch sizes. See
|
||||
[this document][watches] for more info.
|
||||
[watches]:https://github.com/atom/atom/blob/master/docs/build-instructions/linux.md#typeerror-unable-to-watch-path\
|
||||
`
|
||||
atom.notifications.addError(message, {dismissable: true})
|
||||
}
|
||||
|
||||
callback(userSnippetsFileDisposable)
|
||||
} else {
|
||||
callback(new Disposable())
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// Called when a user's snippets file is changed, deleted, or moved so that we
|
||||
// can immediately re-process the snippets it contains.
|
||||
handleUserSnippetsDidChange () {
|
||||
// TODO: There appear to be scenarios where this method gets invoked more
|
||||
// than once with each change to the user's `snippets.cson`. To prevent
|
||||
// more than one concurrent rescan of the snippets file, we block any
|
||||
// additional calls to this method while the first call is still operating.
|
||||
const userSnippetsPath = this.getUserSnippetsPath()
|
||||
|
||||
if (this.isHandlingUserSnippetsChange) {
|
||||
return
|
||||
}
|
||||
|
||||
this.isHandlingUserSnippetsChange = true
|
||||
atom.config.transact(() => {
|
||||
this.clearSnippetsForPath(userSnippetsPath)
|
||||
this.loadSnippetsFile(userSnippetsPath, result => {
|
||||
this.add(userSnippetsPath, result, 'snippets')
|
||||
this.isHandlingUserSnippetsChange = false
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
// Called when the "Enable" checkbox is checked/unchecked in the Snippets
|
||||
// section of a package's settings view.
|
||||
handleDisabledPackagesDidChange (newDisabledPackages = [], oldDisabledPackages = []) {
|
||||
const packagesToAdd = []
|
||||
const packagesToRemove = []
|
||||
for (const p of oldDisabledPackages) {
|
||||
if (!newDisabledPackages.includes(p)) { packagesToAdd.push(p) }
|
||||
}
|
||||
|
||||
for (const p of newDisabledPackages) {
|
||||
if (!oldDisabledPackages.includes(p)) { packagesToRemove.push(p) }
|
||||
}
|
||||
|
||||
atom.config.transact(() => {
|
||||
for (const p of packagesToRemove) { this.removeSnippetsForPackage(p) }
|
||||
for (const p of packagesToAdd) { this.addSnippetsForPackage(p) }
|
||||
})
|
||||
},
|
||||
|
||||
addSnippetsForPackage (packageName) {
|
||||
const snippetSet = this.snippetsByPackage.get(packageName)
|
||||
for (const filePath in snippetSet) {
|
||||
const snippetsBySelector = snippetSet[filePath]
|
||||
this.add(filePath, snippetsBySelector, packageName)
|
||||
}
|
||||
},
|
||||
|
||||
removeSnippetsForPackage (packageName) {
|
||||
const snippetSet = this.snippetsByPackage.get(packageName)
|
||||
// Copy these snippets to the "quarantined" ScopedPropertyStore so that they
|
||||
// remain present in the list of unparsed snippets reported to the settings
|
||||
// view.
|
||||
this.addSnippetsInDisabledPackage(snippetSet)
|
||||
for (const filePath in snippetSet) {
|
||||
this.clearSnippetsForPath(filePath)
|
||||
}
|
||||
},
|
||||
|
||||
loadPackageSnippets (callback) {
|
||||
const disabledPackageNames = atom.config.get('core.packagesWithSnippetsDisabled') || []
|
||||
const packages = atom.packages.getLoadedPackages().sort((pack, _) => {
|
||||
return pack.path.includes(`${path.sep}node_modules${path.sep}`) ? -1 : 1
|
||||
})
|
||||
|
||||
const snippetsDirPaths = []
|
||||
for (const pack of packages) {
|
||||
snippetsDirPaths.push(path.join(pack.path, 'snippets'))
|
||||
}
|
||||
|
||||
async.map(snippetsDirPaths, this.loadSnippetsDirectory.bind(this), (error, results) => {
|
||||
const zipped = []
|
||||
for (const key in results) {
|
||||
zipped.push({result: results[key], pack: packages[key]})
|
||||
}
|
||||
|
||||
const enabledPackages = []
|
||||
for (const o of zipped) {
|
||||
// Skip packages that contain no snippets.
|
||||
if (Object.keys(o.result).length === 0) { continue }
|
||||
// Keep track of which snippets come from which packages so we can
|
||||
// unload them selectively later. All packages get put into this map,
|
||||
// even disabled packages, because we need to know which snippets to add
|
||||
// if those packages are enabled again.
|
||||
this.snippetsByPackage.set(o.pack.name, o.result)
|
||||
if (disabledPackageNames.includes(o.pack.name)) {
|
||||
// Since disabled packages' snippets won't get added to the main
|
||||
// ScopedPropertyStore, we'll keep track of them in a separate
|
||||
// ScopedPropertyStore so that they can still be represented in the
|
||||
// settings view.
|
||||
this.addSnippetsInDisabledPackage(o.result)
|
||||
} else {
|
||||
enabledPackages.push(o.result)
|
||||
}
|
||||
}
|
||||
|
||||
callback(_.extend({}, ...enabledPackages))
|
||||
})
|
||||
},
|
||||
|
||||
doneLoading () {
|
||||
this.loaded = true
|
||||
this.getEmitter().emit('did-load-snippets')
|
||||
},
|
||||
|
||||
onDidLoadSnippets (callback) {
|
||||
this.getEmitter().on('did-load-snippets', callback)
|
||||
},
|
||||
|
||||
getEmitter () {
|
||||
if (this.emitter == null) {
|
||||
this.emitter = new Emitter
|
||||
}
|
||||
return this.emitter
|
||||
},
|
||||
|
||||
loadSnippetsDirectory (snippetsDirPath, callback) {
|
||||
fs.stat(snippetsDirPath, (error, stat) => {
|
||||
if (error || !stat.isDirectory()) return callback(null, {})
|
||||
|
||||
fs.readdir(snippetsDirPath, (error, entries) => {
|
||||
if (error) {
|
||||
console.warn(`Error reading snippets directory ${snippetsDirPath}`, error)
|
||||
return callback(null, {})
|
||||
}
|
||||
|
||||
async.map(
|
||||
entries,
|
||||
(entry, done) => {
|
||||
const filePath = path.join(snippetsDirPath, entry)
|
||||
this.loadSnippetsFile(filePath, snippets => done(null, {filePath, snippets}))
|
||||
},
|
||||
(error, results) => {
|
||||
const snippetsByPath = {}
|
||||
for (const {filePath, snippets} of results) {
|
||||
snippetsByPath[filePath] = snippets
|
||||
}
|
||||
callback(null, snippetsByPath)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
loadSnippetsFile (filePath, callback) {
|
||||
if (!CSON.isObjectPath(filePath)) { return callback({}) }
|
||||
CSON.readFile(filePath, {allowDuplicateKeys: false}, (error, object = {}) => {
|
||||
if (error != null) {
|
||||
console.warn(`Error reading snippets file '${filePath}': ${error.stack != null ? error.stack : error}`)
|
||||
atom.notifications.addError(`Failed to load snippets from '${filePath}'`, {detail: error.message, dismissable: true})
|
||||
}
|
||||
callback(object)
|
||||
})
|
||||
},
|
||||
|
||||
add (filePath, snippetsBySelector, packageName = null, isDisabled = false) {
|
||||
packageName ??= 'snippets'
|
||||
for (const selector in snippetsBySelector) {
|
||||
const snippetsByName = snippetsBySelector[selector]
|
||||
const unparsedSnippetsByPrefix = {}
|
||||
for (const name in snippetsByName) {
|
||||
const attributes = snippetsByName[name]
|
||||
const {prefix, command, body} = attributes
|
||||
if (!prefix && !command) {
|
||||
// A snippet must define either `prefix` or `command`, or both.
|
||||
// TODO: Worth showing notification?
|
||||
console.error(`Skipping snippet ${name}: no "prefix" or "command" property present`)
|
||||
continue
|
||||
}
|
||||
attributes.selector = selector
|
||||
attributes.name = name
|
||||
attributes.id = this.snippetIdCounter++
|
||||
attributes.packageName = packageName
|
||||
// Snippets with "prefix"es will get indexed according to that prefix.
|
||||
// Snippets without "prefix"es will be indexed by their ID below _if_
|
||||
// they have a "command" property. Snippets without "prefix" or
|
||||
// "command" have already been filtered out.
|
||||
if (prefix) {
|
||||
if (typeof body === 'string') {
|
||||
unparsedSnippetsByPrefix[prefix] = attributes
|
||||
} else if (body == null) {
|
||||
unparsedSnippetsByPrefix[prefix] = null
|
||||
}
|
||||
}
|
||||
if (command) {
|
||||
if (!isValidCommandName(command)) {
|
||||
showInvalidCommandNameNotification(name, command)
|
||||
continue
|
||||
}
|
||||
if (!prefix) {
|
||||
// We need a key for these snippets that will not clash with any
|
||||
// prefix key. Since prefixes aren't allowed to have spaces, we'll
|
||||
// put a space in this key.
|
||||
//
|
||||
// We'll use the snippet ID as part of the key. If a snippet's
|
||||
// `command` property clashes with another command, we'll catch
|
||||
// that later.
|
||||
let unparsedSnippetsKey = `command ${attributes.id}`
|
||||
if (typeof body === 'string') {
|
||||
unparsedSnippetsByPrefix[unparsedSnippetsKey] = attributes
|
||||
} else {
|
||||
unparsedSnippetsByPrefix[unparsedSnippetsKey] = null
|
||||
}
|
||||
}
|
||||
if (!isDisabled) {
|
||||
this.addCommandForSnippet(attributes, packageName, selector)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.storeUnparsedSnippets(unparsedSnippetsByPrefix, filePath, selector, packageName, isDisabled)
|
||||
}
|
||||
},
|
||||
|
||||
addCommandForSnippet (attributes, packageName, selector) {
|
||||
packageName = packageName || 'snippets'
|
||||
let {name, command} = attributes
|
||||
let commandName = `${packageName}:${command}`
|
||||
if (CommandMonitor.exists(commandName)) {
|
||||
console.error(`Skipping ${commandName} because it's already been registered!`)
|
||||
showCommandNameConflictNotification(
|
||||
name,
|
||||
commandName,
|
||||
packageName,
|
||||
this.getUserSnippetsPath()
|
||||
)
|
||||
// We won't remove the snippet because it might still be triggerable by
|
||||
// prefix. But we will null out the `command` property to prevent any
|
||||
// possible confusion.
|
||||
attributes.command = null
|
||||
return
|
||||
}
|
||||
|
||||
let commandHandler = (event) => {
|
||||
let editor = event.target.closest('atom-text-editor').getModel()
|
||||
|
||||
// We match the multi-cursor behavior that prefix-triggered snippets
|
||||
// exhibit: only the last cursor determines which scoped set of snippets
|
||||
// we pull, but we'll insert this snippet for each cursor, whether it
|
||||
// happens to be valid for that cursor's scope or not. This could
|
||||
// possibly be refined in the future.
|
||||
let snippets = this.getSnippets(editor)
|
||||
|
||||
let targetSnippet = null
|
||||
for (let snippet of Object.values(snippets)) {
|
||||
if (snippet.id === attributes.id) {
|
||||
targetSnippet = snippet
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetSnippet) {
|
||||
// We don't show an error notification here because it isn't
|
||||
// necessarily a mistake. But we put a warning in the console just in
|
||||
// case the user is confused.
|
||||
console.warn(`Snippet “${name}” not invoked because its scope was not matched.`)
|
||||
|
||||
// Because its scope was not matched, we abort the key binding; this
|
||||
// signals to the key binding resolver that it can pick the next
|
||||
// candidate for a key shortcut, if one exists.
|
||||
return event.abortKeyBinding()
|
||||
}
|
||||
|
||||
this.expandSnippet(editor, targetSnippet)
|
||||
}
|
||||
|
||||
let disposable = atom.commands.add(
|
||||
'atom-text-editor',
|
||||
commandName,
|
||||
commandHandler
|
||||
)
|
||||
|
||||
this.subscriptions.add(disposable)
|
||||
CommandMonitor.add(commandName, disposable)
|
||||
},
|
||||
|
||||
addSnippetsInDisabledPackage (bundle) {
|
||||
for (const filePath in bundle) {
|
||||
const snippetsBySelector = bundle[filePath]
|
||||
const packageName = PackageNameResolver.find(filePath)
|
||||
this.add(filePath, snippetsBySelector, packageName, true)
|
||||
}
|
||||
},
|
||||
|
||||
getScopeChain (object) {
|
||||
let scopesArray = object
|
||||
if (object && object.getScopesArray) {
|
||||
scopesArray = object.getScopesArray()
|
||||
}
|
||||
|
||||
return scopesArray
|
||||
.map(scope => scope[0] === '.' ? scope : `.${scope}`)
|
||||
.join(' ')
|
||||
},
|
||||
|
||||
storeUnparsedSnippets (value, path, selector, packageName, isDisabled = false) {
|
||||
// The `isDisabled` flag determines which scoped property store we'll use.
|
||||
// Active snippets get put into one and inactive snippets get put into
|
||||
// another. Only the first one gets consulted when we look up a snippet
|
||||
// prefix for expansion, but both stores have their contents exported when
|
||||
// the settings view asks for all available snippets.
|
||||
const unparsedSnippets = {}
|
||||
unparsedSnippets[selector] = {"snippets": value}
|
||||
const store = isDisabled ? this.disabledSnippetsScopedPropertyStore : this.scopedPropertyStore
|
||||
store.addProperties(path, unparsedSnippets, {priority: this.priorityForSource(path)})
|
||||
},
|
||||
|
||||
clearSnippetsForPath (path) {
|
||||
for (const scopeSelector in this.scopedPropertyStore.propertiesForSource(path)) {
|
||||
let object = this.scopedPropertyStore.propertiesForSourceAndSelector(path, scopeSelector)
|
||||
if (object.snippets) { object = object.snippets }
|
||||
for (const prefix in object) {
|
||||
const attributes = object[prefix]
|
||||
if (!attributes) { continue }
|
||||
let {command, packageName} = attributes
|
||||
if (packageName && command) {
|
||||
CommandMonitor.remove(`${packageName}:${command}`)
|
||||
}
|
||||
this.parsedSnippetsById.delete(attributes.id)
|
||||
}
|
||||
|
||||
this.scopedPropertyStore.removePropertiesForSourceAndSelector(path, scopeSelector)
|
||||
}
|
||||
},
|
||||
|
||||
parsedSnippetsForScopes (scopeDescriptor) {
|
||||
let unparsedLegacySnippetsByPrefix
|
||||
|
||||
const unparsedSnippetsByPrefix = this.scopedPropertyStore.getPropertyValue(
|
||||
this.getScopeChain(scopeDescriptor),
|
||||
"snippets"
|
||||
)
|
||||
|
||||
const legacyScopeDescriptor = atom.config.getLegacyScopeDescriptorForNewScopeDescriptor
|
||||
? atom.config.getLegacyScopeDescriptorForNewScopeDescriptor(scopeDescriptor)
|
||||
: undefined
|
||||
|
||||
if (legacyScopeDescriptor) {
|
||||
unparsedLegacySnippetsByPrefix = this.scopedPropertyStore.getPropertyValue(
|
||||
this.getScopeChain(legacyScopeDescriptor),
|
||||
"snippets"
|
||||
)
|
||||
}
|
||||
|
||||
const snippets = {}
|
||||
|
||||
if (unparsedSnippetsByPrefix) {
|
||||
for (const prefix in unparsedSnippetsByPrefix) {
|
||||
const attributes = unparsedSnippetsByPrefix[prefix]
|
||||
if (typeof (attributes != null ? attributes.body : undefined) !== 'string') { continue }
|
||||
snippets[prefix] = this.getParsedSnippet(attributes)
|
||||
}
|
||||
}
|
||||
|
||||
if (unparsedLegacySnippetsByPrefix) {
|
||||
for (const prefix in unparsedLegacySnippetsByPrefix) {
|
||||
const attributes = unparsedLegacySnippetsByPrefix[prefix]
|
||||
if (snippets[prefix]) { continue }
|
||||
if (typeof (attributes != null ? attributes.body : undefined) !== 'string') { continue }
|
||||
snippets[prefix] = this.getParsedSnippet(attributes)
|
||||
}
|
||||
}
|
||||
|
||||
return snippets
|
||||
},
|
||||
|
||||
getParsedSnippet (attributes) {
|
||||
let snippet = this.parsedSnippetsById.get(attributes.id)
|
||||
if (snippet == null) {
|
||||
let {id, prefix, command, name, body, bodyTree, description, packageName, descriptionMoreURL, rightLabelHTML, leftLabel, leftLabelHTML, selector} = attributes
|
||||
if (bodyTree == null) { bodyTree = this.getBodyParser().parse(body) }
|
||||
snippet = new Snippet({id, name, prefix, command, bodyTree, description, packageName, descriptionMoreURL, rightLabelHTML, leftLabel, leftLabelHTML, selector, bodyText: body})
|
||||
this.parsedSnippetsById.set(attributes.id, snippet)
|
||||
}
|
||||
return snippet
|
||||
},
|
||||
|
||||
priorityForSource (source) {
|
||||
if (source === this.getUserSnippetsPath()) {
|
||||
return 1000
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
},
|
||||
|
||||
getBodyParser () {
|
||||
if (this.bodyParser == null) {
|
||||
this.bodyParser = require('./snippet-body-parser')
|
||||
}
|
||||
return this.bodyParser
|
||||
},
|
||||
|
||||
// Get an {Object} with these keys:
|
||||
// * `snippetPrefix`: the possible snippet prefix text preceding the cursor
|
||||
// * `wordPrefix`: the word preceding the cursor
|
||||
//
|
||||
// Returns `null` if the values aren't the same for all cursors
|
||||
getPrefixText (snippets, editor) {
|
||||
const wordRegex = this.wordRegexForSnippets(snippets)
|
||||
|
||||
let snippetPrefix = null
|
||||
let wordPrefix = null
|
||||
|
||||
for (const cursor of editor.getCursors()) {
|
||||
const position = cursor.getBufferPosition()
|
||||
|
||||
const prefixStart = cursor.getBeginningOfCurrentWordBufferPosition({wordRegex})
|
||||
const cursorSnippetPrefix = editor.getTextInRange([prefixStart, position])
|
||||
if ((snippetPrefix != null) && (cursorSnippetPrefix !== snippetPrefix)) { return null }
|
||||
snippetPrefix = cursorSnippetPrefix
|
||||
|
||||
const wordStart = cursor.getBeginningOfCurrentWordBufferPosition()
|
||||
const cursorWordPrefix = editor.getTextInRange([wordStart, position])
|
||||
if ((wordPrefix != null) && (cursorWordPrefix !== wordPrefix)) { return null }
|
||||
wordPrefix = cursorWordPrefix
|
||||
}
|
||||
|
||||
return {snippetPrefix, wordPrefix}
|
||||
},
|
||||
|
||||
// Get a RegExp of all the characters used in the snippet prefixes
|
||||
wordRegexForSnippets (snippets) {
|
||||
const prefixes = {}
|
||||
|
||||
for (const prefix in snippets) {
|
||||
for (const character of prefix) { prefixes[character] = true }
|
||||
}
|
||||
|
||||
const prefixCharacters = Object.keys(prefixes).join('')
|
||||
return new RegExp(`[${_.escapeRegExp(prefixCharacters)}]+`)
|
||||
},
|
||||
|
||||
// Get the best match snippet for the given prefix text. This will return
|
||||
// the longest match where there is no exact match to the prefix text.
|
||||
snippetForPrefix (snippets, prefix, wordPrefix) {
|
||||
let longestPrefixMatch = null
|
||||
|
||||
for (const snippetPrefix in snippets) {
|
||||
// Any snippet without a prefix was keyed on its snippet ID, but with a
|
||||
// space introduced to ensure it would never be a prefix match. But let's
|
||||
// play it safe here anyway.
|
||||
if (snippetPrefix.includes(' ')) { continue }
|
||||
const snippet = snippets[snippetPrefix]
|
||||
if (prefix.endsWith(snippetPrefix) && (wordPrefix.length <= snippetPrefix.length)) {
|
||||
if ((longestPrefixMatch == null) || (snippetPrefix.length > longestPrefixMatch.prefix.length)) {
|
||||
longestPrefixMatch = snippet
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return longestPrefixMatch
|
||||
},
|
||||
|
||||
getSnippets (editor) {
|
||||
return this.parsedSnippetsForScopes(editor.getLastCursor().getScopeDescriptor())
|
||||
},
|
||||
|
||||
snippetToExpandUnderCursor (editor) {
|
||||
if (!editor.getLastSelection().isEmpty()) { return false }
|
||||
const snippets = this.getSnippets(editor)
|
||||
if (_.isEmpty(snippets)) { return false }
|
||||
|
||||
const prefixData = this.getPrefixText(snippets, editor)
|
||||
if (prefixData) {
|
||||
return this.snippetForPrefix(snippets, prefixData.snippetPrefix, prefixData.wordPrefix)
|
||||
}
|
||||
},
|
||||
|
||||
// Expands a snippet invoked via command.
|
||||
expandSnippet (editor, snippet) {
|
||||
this.getStore(editor).observeHistory({
|
||||
undo: event => { this.onUndoOrRedo(editor, event, true) },
|
||||
redo: event => { this.onUndoOrRedo(editor, event, false) }
|
||||
})
|
||||
|
||||
this.findOrCreateMarkerLayer(editor)
|
||||
|
||||
editor.transact(() => {
|
||||
const cursors = editor.getCursors()
|
||||
for (const cursor of cursors) {
|
||||
this.insert(snippet, editor, cursor, {method: 'command'})
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// Expands a snippet defined via tab trigger _if_ such a snippet can be found
|
||||
// for the current prefix and scope.
|
||||
expandSnippetsUnderCursors (editor) {
|
||||
const snippet = this.snippetToExpandUnderCursor(editor)
|
||||
if (!snippet) { return false }
|
||||
|
||||
this.getStore(editor).observeHistory({
|
||||
undo: event => { this.onUndoOrRedo(editor, event, true) },
|
||||
redo: event => { this.onUndoOrRedo(editor, event, false) }
|
||||
})
|
||||
|
||||
this.findOrCreateMarkerLayer(editor)
|
||||
editor.transact(() => {
|
||||
const cursors = editor.getCursors()
|
||||
for (const cursor of cursors) {
|
||||
// Select the prefix text so that it gets consumed when the snippet
|
||||
// expands.
|
||||
const cursorPosition = cursor.getBufferPosition()
|
||||
const startPoint = cursorPosition.translate([0, -snippet.prefix.length], [0, 0])
|
||||
cursor.selection.setBufferRange([startPoint, cursorPosition])
|
||||
this.insert(snippet, editor, cursor, {method: 'prefix'})
|
||||
}
|
||||
})
|
||||
return true
|
||||
},
|
||||
|
||||
goToNextTabStop (editor) {
|
||||
let nextTabStopVisited = false
|
||||
for (const expansion of this.getExpansions(editor)) {
|
||||
if (expansion && expansion.goToNextTabStop()) {
|
||||
nextTabStopVisited = true
|
||||
}
|
||||
}
|
||||
return nextTabStopVisited
|
||||
},
|
||||
|
||||
goToPreviousTabStop (editor) {
|
||||
let previousTabStopVisited = false
|
||||
for (const expansion of this.getExpansions(editor)) {
|
||||
if (expansion && expansion.goToPreviousTabStop()) {
|
||||
previousTabStopVisited = true
|
||||
}
|
||||
}
|
||||
return previousTabStopVisited
|
||||
},
|
||||
|
||||
getStore (editor) {
|
||||
return EditorStore.findOrCreate(editor)
|
||||
},
|
||||
|
||||
findOrCreateMarkerLayer (editor) {
|
||||
let layer = this.editorMarkerLayers.get(editor)
|
||||
if (layer === undefined) {
|
||||
layer = editor.addMarkerLayer({maintainHistory: true})
|
||||
this.editorMarkerLayers.set(editor, layer)
|
||||
}
|
||||
return layer
|
||||
},
|
||||
|
||||
getExpansions (editor) {
|
||||
return this.getStore(editor).getExpansions()
|
||||
},
|
||||
|
||||
clearExpansions (editor) {
|
||||
const store = this.getStore(editor)
|
||||
store.clearExpansions()
|
||||
// There are no more active instances of this expansion, so we should undo
|
||||
// the spying we set up on this editor.
|
||||
store.stopObserving()
|
||||
store.stopObservingHistory()
|
||||
},
|
||||
|
||||
addExpansion (editor, snippetExpansion) {
|
||||
this.getStore(editor).addExpansion(snippetExpansion)
|
||||
},
|
||||
|
||||
textChanged (editor, event) {
|
||||
const store = this.getStore(editor)
|
||||
const activeExpansions = store.getExpansions()
|
||||
|
||||
if ((activeExpansions.length === 0) || activeExpansions[0].isIgnoringBufferChanges) { return }
|
||||
|
||||
this.ignoringTextChangesForEditor(editor, () =>
|
||||
editor.transact(() =>
|
||||
activeExpansions.map(expansion => expansion.textChanged(event)))
|
||||
)
|
||||
|
||||
// Create a checkpoint here to consolidate all the changes we just made into
|
||||
// the transaction that prompted them.
|
||||
this.makeCheckpoint(editor)
|
||||
},
|
||||
|
||||
// Perform an action inside the editor without triggering our `textChanged`
|
||||
// callback.
|
||||
ignoringTextChangesForEditor (editor, callback) {
|
||||
this.stopObservingEditor(editor)
|
||||
callback()
|
||||
this.observeEditor(editor)
|
||||
},
|
||||
|
||||
observeEditor (editor) {
|
||||
this.getStore(editor).observe(event => this.textChanged(editor, event))
|
||||
},
|
||||
|
||||
stopObservingEditor (editor) {
|
||||
this.getStore(editor).stopObserving()
|
||||
},
|
||||
|
||||
makeCheckpoint (editor) {
|
||||
this.getStore(editor).makeCheckpoint()
|
||||
},
|
||||
|
||||
insert (snippet, editor, cursor, {method = null} = {}) {
|
||||
if (editor == null) { editor = atom.workspace.getActiveTextEditor() }
|
||||
if (cursor == null) { cursor = editor.getLastCursor() }
|
||||
if (typeof snippet === 'string') {
|
||||
const bodyTree = this.getBodyParser().parse(snippet)
|
||||
snippet = new Snippet({id: this.snippetIdCounter++, name: '__anonymous', prefix: '', bodyTree, bodyText: snippet})
|
||||
}
|
||||
return new SnippetExpansion(snippet, editor, cursor, this, {method})
|
||||
},
|
||||
|
||||
getUnparsedSnippets () {
|
||||
const results = []
|
||||
const iterate = sets => {
|
||||
for (const item of sets) {
|
||||
const newItem = _.deepClone(item)
|
||||
// The atom-slick library has already parsed the `selector` property,
|
||||
// so it's an AST here instead of a string. The object has a `toString`
|
||||
// method that turns it back into a string. That custom behavior won't
|
||||
// be preserved in the deep clone of the object, so we have to handle
|
||||
// it separately.
|
||||
newItem.selectorString = item.selector.toString()
|
||||
results.push(newItem)
|
||||
}
|
||||
}
|
||||
|
||||
iterate(this.scopedPropertyStore.propertySets)
|
||||
iterate(this.disabledSnippetsScopedPropertyStore.propertySets)
|
||||
return results
|
||||
},
|
||||
|
||||
provideSnippets () {
|
||||
return {
|
||||
bundledSnippetsLoaded: () => this.loaded,
|
||||
insertSnippet: this.insert.bind(this),
|
||||
snippetsForScopes: this.parsedSnippetsForScopes.bind(this),
|
||||
getUnparsedSnippets: this.getUnparsedSnippets.bind(this),
|
||||
getUserSnippetsPath: this.getUserSnippetsPath.bind(this)
|
||||
}
|
||||
},
|
||||
|
||||
onUndoOrRedo (editor, event, isUndo) {
|
||||
const activeExpansions = this.getExpansions(editor)
|
||||
activeExpansions.forEach(expansion => expansion.onUndoOrRedo(isUndo))
|
||||
}
|
||||
}
|
48
packages/snippets/lib/tab-stop-list.js
Normal file
48
packages/snippets/lib/tab-stop-list.js
Normal file
@ -0,0 +1,48 @@
|
||||
const TabStop = require('./tab-stop')
|
||||
|
||||
class TabStopList {
|
||||
constructor (snippet) {
|
||||
this.snippet = snippet
|
||||
this.list = {}
|
||||
}
|
||||
|
||||
get length () {
|
||||
return Object.keys(this.list).length
|
||||
}
|
||||
|
||||
get hasEndStop () {
|
||||
return !!this.list[Infinity]
|
||||
}
|
||||
|
||||
findOrCreate ({ index, snippet }) {
|
||||
if (!this.list[index]) {
|
||||
this.list[index] = new TabStop({ index, snippet })
|
||||
}
|
||||
return this.list[index]
|
||||
}
|
||||
|
||||
forEachIndex (iterator) {
|
||||
let indices = Object.keys(this.list).sort((a1, a2) => a1 - a2)
|
||||
indices.forEach(iterator)
|
||||
}
|
||||
|
||||
getInsertions () {
|
||||
let results = []
|
||||
this.forEachIndex(index => {
|
||||
results.push(...this.list[index].insertions)
|
||||
})
|
||||
return results
|
||||
}
|
||||
|
||||
toArray () {
|
||||
let results = []
|
||||
this.forEachIndex(index => {
|
||||
let tabStop = this.list[index]
|
||||
if (!tabStop.isValid()) return
|
||||
results.push(tabStop)
|
||||
})
|
||||
return results
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TabStopList
|
61
packages/snippets/lib/tab-stop.js
Normal file
61
packages/snippets/lib/tab-stop.js
Normal file
@ -0,0 +1,61 @@
|
||||
const {Range} = require('atom')
|
||||
const Insertion = require('./insertion')
|
||||
|
||||
// A tab stop:
|
||||
// * belongs to a snippet
|
||||
// * has an index (one tab stop per index)
|
||||
// * has multiple Insertions
|
||||
class TabStop {
|
||||
constructor ({ snippet, index, insertions }) {
|
||||
this.insertions = insertions || []
|
||||
Object.assign(this, { snippet, index })
|
||||
}
|
||||
|
||||
isValid () {
|
||||
let any = this.insertions.some(insertion => insertion.isTransformation())
|
||||
if (!any) return true
|
||||
let all = this.insertions.every(insertion => insertion.isTransformation())
|
||||
// If there are any transforming insertions, there must be at least one
|
||||
// non-transforming insertion to act as the primary.
|
||||
return !all
|
||||
}
|
||||
|
||||
addInsertion ({ range, substitution, references }) {
|
||||
let insertion = new Insertion({ range, substitution, references })
|
||||
let insertions = this.insertions
|
||||
insertions.push(insertion)
|
||||
insertions = insertions.sort((i1, i2) => {
|
||||
return i1.range.start.compare(i2.range.start)
|
||||
})
|
||||
let initial = insertions.find(insertion => !insertion.isTransformation())
|
||||
if (initial) {
|
||||
insertions.splice(insertions.indexOf(initial), 1)
|
||||
insertions.unshift(initial)
|
||||
}
|
||||
this.insertions = insertions
|
||||
}
|
||||
|
||||
copyWithIndent (indent) {
|
||||
let { snippet, index, insertions } = this
|
||||
let newInsertions = insertions.map(insertion => {
|
||||
let { range, substitution } = insertion
|
||||
let newRange = Range.fromObject(range, true)
|
||||
if (newRange.start.row) {
|
||||
newRange.start.column += indent.length
|
||||
newRange.end.column += indent.length
|
||||
}
|
||||
return new Insertion({
|
||||
range: newRange,
|
||||
substitution
|
||||
})
|
||||
})
|
||||
|
||||
return new TabStop({
|
||||
snippet,
|
||||
index,
|
||||
insertions: newInsertions
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TabStop
|
235
packages/snippets/lib/variable.js
Normal file
235
packages/snippets/lib/variable.js
Normal file
@ -0,0 +1,235 @@
|
||||
const path = require('path')
|
||||
const crypto = require('crypto')
|
||||
const Replacer = require('./replacer')
|
||||
const FLAGS = require('./simple-transformations')
|
||||
const {remote} = require('electron')
|
||||
|
||||
function resolveClipboard () {
|
||||
return atom.clipboard.read()
|
||||
}
|
||||
|
||||
function makeDateResolver (dateParams) {
|
||||
// TODO: I do not know if this method ever returns anything other than
|
||||
// 'en-us'; I suspect it does not. But this is likely the forward-compatible
|
||||
// way of doing things.
|
||||
//
|
||||
// On the other hand, if the output of CURRENT_* variables _did_ vary based
|
||||
// on locale, we'd probably need to implement a setting to force an arbitrary
|
||||
// locale. I imagine lots of people use their native language for their OS's
|
||||
// locale but write code in English.
|
||||
//
|
||||
let locale = remote.app.getLocale()
|
||||
return () => new Date().toLocaleString(locale, dateParams)
|
||||
}
|
||||
|
||||
const RESOLVERS = {
|
||||
// All the TM_-prefixed variables are part of the LSP specification for
|
||||
// snippets.
|
||||
'TM_SELECTED_TEXT' ({editor, selectionRange, method}) {
|
||||
// When a snippet is inserted via tab trigger, the trigger is
|
||||
// programmatically selected prior to snippet expansion so that it is
|
||||
// consumed when the snippet body is inserted. The trigger _should not_ be
|
||||
// treated as selected text. There is no way for $TM_SELECTED_TEXT to
|
||||
// contain anything when a snippet is invoked via tab trigger.
|
||||
if (method === 'prefix') return ''
|
||||
|
||||
if (!selectionRange || selectionRange.isEmpty()) return ''
|
||||
return editor.getTextInBufferRange(selectionRange)
|
||||
},
|
||||
'TM_CURRENT_LINE' ({editor, cursor}) {
|
||||
return editor.lineTextForBufferRow(cursor.getBufferRow())
|
||||
},
|
||||
'TM_CURRENT_WORD' ({editor, cursor}) {
|
||||
return editor.getTextInBufferRange(cursor.getCurrentWordBufferRange())
|
||||
},
|
||||
'TM_LINE_INDEX' ({cursor}) {
|
||||
return `${cursor.getBufferRow()}`
|
||||
},
|
||||
'TM_LINE_NUMBER' ({cursor}) {
|
||||
return `${cursor.getBufferRow() + 1}`
|
||||
},
|
||||
'TM_FILENAME' ({editor}) {
|
||||
return editor.getTitle()
|
||||
},
|
||||
'TM_FILENAME_BASE' ({editor}) {
|
||||
let fileName = editor.getTitle()
|
||||
if (!fileName) { return undefined }
|
||||
|
||||
const index = fileName.lastIndexOf('.')
|
||||
if (index >= 0) {
|
||||
return fileName.slice(0, index)
|
||||
}
|
||||
return fileName
|
||||
},
|
||||
'TM_FILEPATH' ({editor}) {
|
||||
return editor.getPath()
|
||||
},
|
||||
'TM_DIRECTORY' ({editor}) {
|
||||
const filePath = editor.getPath()
|
||||
if (filePath === undefined) return undefined
|
||||
return path.dirname(filePath)
|
||||
},
|
||||
|
||||
// VSCode supports these.
|
||||
'CLIPBOARD': resolveClipboard,
|
||||
|
||||
'CURRENT_YEAR': makeDateResolver({year: 'numeric'}),
|
||||
'CURRENT_YEAR_SHORT': makeDateResolver({year: '2-digit'}),
|
||||
'CURRENT_MONTH': makeDateResolver({month: '2-digit'}),
|
||||
'CURRENT_MONTH_NAME': makeDateResolver({month: 'long'}),
|
||||
'CURRENT_MONTH_NAME_SHORT': makeDateResolver({month: 'short'}),
|
||||
'CURRENT_DATE': makeDateResolver({day: '2-digit'}),
|
||||
'CURRENT_DAY_NAME': makeDateResolver({weekday: 'long'}),
|
||||
'CURRENT_DAY_NAME_SHORT': makeDateResolver({weekday: 'short'}),
|
||||
'CURRENT_HOUR': makeDateResolver({hour12: false, hour: '2-digit'}),
|
||||
'CURRENT_MINUTE': makeDateResolver({minute: '2-digit'}),
|
||||
'CURRENT_SECOND': makeDateResolver({second: '2-digit'}),
|
||||
'CURRENT_SECONDS_UNIX': () => {
|
||||
return Math.floor( Date.now() / 1000 )
|
||||
},
|
||||
|
||||
// NOTE: "Ancestor project path" is determined as follows:
|
||||
//
|
||||
// * Get all project paths via `atom.project.getPaths()`.
|
||||
// * Return the first path (in the order we received) that is an ancestor of
|
||||
// the current file in the editor.
|
||||
|
||||
// The current file's path relative to the ancestor project path.
|
||||
'RELATIVE_FILEPATH' ({editor}) {
|
||||
let filePath = editor.getPath()
|
||||
let projectPaths = atom.project.getPaths()
|
||||
if (projectPaths.length === 0) { return filePath }
|
||||
// A project can have multiple path roots. Return whichever is the first
|
||||
// that is an ancestor of the file path.
|
||||
let ancestor = projectPaths.find(pp => {
|
||||
return filePath.startsWith(`${pp}${path.sep}`)
|
||||
})
|
||||
if (!ancestor) return {filePath}
|
||||
|
||||
return filePath.substring(ancestor.length)
|
||||
},
|
||||
|
||||
// Last path component of the ancestor project path.
|
||||
'WORKSPACE_NAME' ({editor}) {
|
||||
let projectPaths = atom.project.getPaths()
|
||||
if (projectPaths.length === 0) { return '' }
|
||||
let filePath = editor.getPath()
|
||||
let ancestor = projectPaths.find(pp => {
|
||||
return filePath.startsWith(`${pp}${path.sep}`)
|
||||
})
|
||||
|
||||
return path.basename(ancestor)
|
||||
},
|
||||
|
||||
// The full path to the ancestor project path.
|
||||
'WORKSPACE_FOLDER' ({editor}) {
|
||||
let projectPaths = atom.project.getPaths()
|
||||
if (projectPaths.length === 0) { return '' }
|
||||
let filePath = editor.getPath()
|
||||
let ancestor = projectPaths.find(pp => {
|
||||
return filePath.startsWith(`${pp}${path.sep}`)
|
||||
})
|
||||
|
||||
return ancestor
|
||||
},
|
||||
|
||||
'CURSOR_INDEX' ({editor, cursor}) {
|
||||
let cursors = editor.getCursors()
|
||||
let index = cursors.indexOf(cursor)
|
||||
return index >= 0 ? String(index) : ''
|
||||
},
|
||||
|
||||
'CURSOR_NUMBER' ({editor, cursor}) {
|
||||
let cursors = editor.getCursors()
|
||||
let index = cursors.indexOf(cursor)
|
||||
return index >= 0 ? String(index + 1) : ''
|
||||
},
|
||||
|
||||
'RANDOM' () {
|
||||
return Math.random().toString().slice(-6)
|
||||
},
|
||||
|
||||
'RANDOM_HEX' () {
|
||||
return Math.random().toString(16).slice(-6)
|
||||
},
|
||||
|
||||
'BLOCK_COMMENT_START' ({editor, cursor}) {
|
||||
let delimiters = editor.getCommentDelimitersForBufferPosition(
|
||||
cursor.getBufferPosition()
|
||||
)
|
||||
return (delimiters?.block?.[0] ?? '').trim()
|
||||
},
|
||||
|
||||
'BLOCK_COMMENT_END' ({editor, cursor}) {
|
||||
let delimiters = editor.getCommentDelimitersForBufferPosition(
|
||||
cursor.getBufferPosition()
|
||||
)
|
||||
return (delimiters?.block?.[1] ?? '').trim()
|
||||
},
|
||||
|
||||
'LINE_COMMENT' ({editor, cursor}) {
|
||||
let delimiters = editor.getCommentDelimitersForBufferPosition(
|
||||
cursor.getBufferPosition()
|
||||
)
|
||||
return (delimiters?.line ?? '').trim()
|
||||
}
|
||||
|
||||
// TODO: VSCode also supports:
|
||||
//
|
||||
// UUID
|
||||
//
|
||||
// (can be done without dependencies once we use Node >= 14.17.0 or >=
|
||||
// 15.6.0; see below)
|
||||
//
|
||||
}
|
||||
|
||||
// $UUID will be easy to implement once Pulsar runs a newer version of Node, so
|
||||
// there's no reason not to be proactive and sniff for the function we need.
|
||||
if (('randomUUID' in crypto) && (typeof crypto.randomUUID === 'function')) {
|
||||
RESOLVERS['UUID'] = () => {
|
||||
return crypto.randomUUID({disableEntropyCache: true})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function replaceByFlag (text, flag) {
|
||||
let replacer = FLAGS[flag]
|
||||
if (!replacer) { return text }
|
||||
return replacer(text)
|
||||
}
|
||||
|
||||
class Variable {
|
||||
constructor ({point, snippet, variable: name, substitution}) {
|
||||
Object.assign(this, {point, snippet, name, substitution})
|
||||
}
|
||||
|
||||
resolve (params) {
|
||||
let base = ''
|
||||
if (this.name in RESOLVERS) {
|
||||
base = RESOLVERS[this.name](params)
|
||||
}
|
||||
|
||||
if (!this.substitution) {
|
||||
return base
|
||||
}
|
||||
|
||||
let {flag, find, replace} = this.substitution
|
||||
|
||||
// Two kinds of substitution.
|
||||
if (flag) {
|
||||
// This is the kind with the trailing `:/upcase`, `:/downcase`, etc.
|
||||
return replaceByFlag(base, flag)
|
||||
} else if (find && replace) {
|
||||
// This is the more complex sed-style substitution.
|
||||
let {find, replace} = this.substitution
|
||||
this.replacer ??= new Replacer(replace)
|
||||
return base.replace(find, (...args) => {
|
||||
return this.replacer.replace(...args)
|
||||
})
|
||||
} else {
|
||||
return base
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Variable
|
12
packages/snippets/menus/snippets.cson
Normal file
12
packages/snippets/menus/snippets.cson
Normal file
@ -0,0 +1,12 @@
|
||||
'menu': [
|
||||
'label': 'Packages'
|
||||
'submenu': [
|
||||
'label': 'Snippets'
|
||||
'submenu': [
|
||||
{ 'label': 'Expand', 'command': 'snippets:show' }
|
||||
{ 'label': 'Next Stop', 'command': 'snippets:next-tab-stop' }
|
||||
{ 'label': 'Previous Stop', 'command': 'snippets:previous-tab-stop' }
|
||||
{ 'label': 'Available', 'command': 'snippets:available' }
|
||||
]
|
||||
]
|
||||
]
|
2574
packages/snippets/package-lock.json
generated
Normal file
2574
packages/snippets/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
packages/snippets/package.json
Normal file
31
packages/snippets/package.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "snippets",
|
||||
"version": "1.8.0",
|
||||
"main": "./lib/snippets",
|
||||
"description": "Expand snippets matching the current prefix with `tab`.",
|
||||
"repository": "https://github.com/pulsar-edit/pulsar",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"atom": "*"
|
||||
},
|
||||
"dependencies": {
|
||||
"async": "~0.2.6",
|
||||
"atom-select-list": "^0.7.0",
|
||||
"pegjs": "^0.10.0",
|
||||
"scoped-property-store": "^0.17.0",
|
||||
"season": "^6.0.2",
|
||||
"temp": "~0.8.0",
|
||||
"underscore-plus": "^1.0.0"
|
||||
},
|
||||
"providedServices": {
|
||||
"snippets": {
|
||||
"description": "Snippets are text shortcuts that can be expanded to their definition.",
|
||||
"versions": {
|
||||
"0.1.0": "provideSnippets"
|
||||
}
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.35.0"
|
||||
}
|
||||
}
|
5
packages/snippets/spec/.eslintrc
Normal file
5
packages/snippets/spec/.eslintrc
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"rules": {
|
||||
"semi": ["error", "always"]
|
||||
}
|
||||
}
|
704
packages/snippets/spec/body-parser-spec.js
Normal file
704
packages/snippets/spec/body-parser-spec.js
Normal file
@ -0,0 +1,704 @@
|
||||
const BodyParser = require('../lib/snippet-body-parser');
|
||||
|
||||
function expectMatch (input, tree) {
|
||||
expect(BodyParser.parse(input)).toEqual(tree);
|
||||
}
|
||||
|
||||
describe("Snippet Body Parser", () => {
|
||||
it("parses a snippet with no special behavior", () => {
|
||||
const bodyTree = BodyParser.parse('${} $ n $}1} ${/upcase/} \n world ${||}');
|
||||
expect(bodyTree).toEqual([
|
||||
'${} $ n $}1} ${/upcase/} \n world ${||}'
|
||||
]);
|
||||
});
|
||||
|
||||
describe('for snippets with variables', () => {
|
||||
it('parses simple variables', () => {
|
||||
expectMatch('$f_o_0', [{variable: 'f_o_0'}]);
|
||||
expectMatch('$_FOO', [{variable: '_FOO'}]);
|
||||
});
|
||||
|
||||
it('parses verbose variables', () => {
|
||||
expectMatch('${foo}', [{variable: 'foo'}]);
|
||||
expectMatch('${FOO}', [{variable: 'FOO'}]);
|
||||
});
|
||||
|
||||
it('parses variables with placeholders', () => {
|
||||
expectMatch(
|
||||
'${f:placeholder}',
|
||||
[{variable: 'f', content: ['placeholder']}]
|
||||
);
|
||||
|
||||
expectMatch(
|
||||
'${f:foo$1 $VAR}',
|
||||
[
|
||||
{
|
||||
variable: 'f',
|
||||
content: [
|
||||
'foo',
|
||||
{index: 1, content: []},
|
||||
' ',
|
||||
{variable: 'VAR'}
|
||||
]
|
||||
}
|
||||
]
|
||||
);
|
||||
|
||||
// Allows a colon as part of the placeholder value.
|
||||
expectMatch(
|
||||
'${TM_SELECTED_TEXT:foo:bar}',
|
||||
[
|
||||
{
|
||||
variable: 'TM_SELECTED_TEXT',
|
||||
content: [
|
||||
'foo:bar'
|
||||
]
|
||||
}
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it('parses simple transformations like /upcase', () => {
|
||||
const bodyTree = BodyParser.parse("lorem ipsum ${CLIPBOARD:/upcase} dolor sit amet");
|
||||
expectMatch(
|
||||
"lorem ipsum ${CLIPBOARD:/upcase} dolor sit amet",
|
||||
[
|
||||
"lorem ipsum ",
|
||||
{
|
||||
variable: 'CLIPBOARD',
|
||||
substitution: {flag: 'upcase'}
|
||||
},
|
||||
" dolor sit amet"
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it('parses variables with transforms', () => {
|
||||
expectMatch('${f/.*/$0/}', [
|
||||
{
|
||||
variable: 'f',
|
||||
substitution: {
|
||||
find: /.*/,
|
||||
replace: [
|
||||
{backreference: 0}
|
||||
]
|
||||
}
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('for snippets with tabstops', () => {
|
||||
it('parses simple tabstops', () => {
|
||||
expectMatch('hello$1world$2', [
|
||||
'hello',
|
||||
{index: 1, content: []},
|
||||
'world',
|
||||
{index: 2, content: []}
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses verbose tabstops', () => {
|
||||
expectMatch('hello${1}world${2}', [
|
||||
'hello',
|
||||
{index: 1, content: []},
|
||||
'world',
|
||||
{index: 2, content: []}
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips escaped tabstops', () => {
|
||||
expectMatch('$1 \\$2 $3 \\\\$4 \\\\\\$5 $6', [
|
||||
{index: 1, content: []},
|
||||
' $2 ',
|
||||
{index: 3, content: []},
|
||||
' \\',
|
||||
{index: 4, content: []},
|
||||
' \\$5 ',
|
||||
{index: 6, content: []}
|
||||
]);
|
||||
});
|
||||
|
||||
describe('for tabstops with placeholders', () => {
|
||||
it('parses them', () => {
|
||||
expectMatch('hello${1:placeholder}world', [
|
||||
'hello',
|
||||
{index: 1, content: ['placeholder']},
|
||||
'world'
|
||||
]);
|
||||
});
|
||||
|
||||
it('allows escaped back braces', () => {
|
||||
expectMatch('${1:{}}', [
|
||||
{index: 1, content: ['{']},
|
||||
'}'
|
||||
]);
|
||||
expectMatch('${1:{\\}}', [
|
||||
{index: 1, content: ['{}']}
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('parses tabstops with transforms', () => {
|
||||
expectMatch('${1/.*/$0/}', [
|
||||
{
|
||||
index: 1,
|
||||
content: [],
|
||||
substitution: {
|
||||
find: /.*/,
|
||||
replace: [{backreference: 0}]
|
||||
}
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses tabstops with choices', () => {
|
||||
expectMatch('${1|on}e,t\\|wo,th\\,ree|}', [
|
||||
{index: 1, content: ['on}e'], choice: ['on}e', 't|wo', 'th,ree']}
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses if-else syntax', () => {
|
||||
expectMatch(
|
||||
'$1 ${1/(?:(wat)|^.*$)$/${1:+hey}/}',
|
||||
[
|
||||
{index: 1, content: []},
|
||||
" ",
|
||||
{
|
||||
index: 1,
|
||||
content: [],
|
||||
substitution: {
|
||||
find: /(?:(wat)|^.*$)$/,
|
||||
replace: [
|
||||
{
|
||||
backreference: 1,
|
||||
iftext: "hey",
|
||||
elsetext: ""
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
expectMatch(
|
||||
'$1 ${1/(?:(wat)|^.*$)$/${1:?hey:nah}/}',
|
||||
[
|
||||
{index: 1, content: []},
|
||||
" ",
|
||||
{
|
||||
index: 1,
|
||||
content: [],
|
||||
substitution: {
|
||||
find: /(?:(wat)|^.*$)$/,
|
||||
replace: [
|
||||
{
|
||||
backreference: 1,
|
||||
iftext: "hey",
|
||||
elsetext: "nah"
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
// else with `:` syntax
|
||||
expectMatch(
|
||||
'$1 ${1/(?:(wat)|^.*$)$/${1:fallback}/}',
|
||||
[
|
||||
{index: 1, content: []},
|
||||
" ",
|
||||
{
|
||||
index: 1,
|
||||
content: [],
|
||||
substitution: {
|
||||
find: /(?:(wat)|^.*$)$/,
|
||||
replace: [
|
||||
{
|
||||
backreference: 1,
|
||||
iftext: "",
|
||||
elsetext: "fallback"
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
// else with `:-` syntax; should be same as above
|
||||
expectMatch(
|
||||
'$1 ${1/(?:(wat)|^.*$)$/${1:-fallback}/}',
|
||||
[
|
||||
{index: 1, content: []},
|
||||
" ",
|
||||
{
|
||||
index: 1,
|
||||
content: [],
|
||||
substitution: {
|
||||
find: /(?:(wat)|^.*$)$/,
|
||||
replace: [
|
||||
{
|
||||
backreference: 1,
|
||||
iftext: "",
|
||||
elsetext: "fallback"
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
});
|
||||
|
||||
it('parses alternative if-else syntax', () => {
|
||||
expectMatch(
|
||||
'$1 ${1/(?:(wat)|^.*$)$/(?1:hey:)/}',
|
||||
[
|
||||
{index: 1, content: []},
|
||||
" ",
|
||||
{
|
||||
index: 1,
|
||||
content: [],
|
||||
substitution: {
|
||||
find: /(?:(wat)|^.*$)$/,
|
||||
replace: [
|
||||
{
|
||||
backreference: 1,
|
||||
iftext: ["hey"],
|
||||
elsetext: ""
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
expectMatch(
|
||||
'$1 ${1/(?:(wat)|^.*$)$/(?1:\\u$1:)/}',
|
||||
[
|
||||
{index: 1, content: []},
|
||||
" ",
|
||||
{
|
||||
index: 1,
|
||||
content: [],
|
||||
substitution: {
|
||||
find: /(?:(wat)|^.*$)$/,
|
||||
replace: [
|
||||
{
|
||||
backreference: 1,
|
||||
iftext: [
|
||||
{escape: 'u'},
|
||||
{backreference: 1}
|
||||
],
|
||||
elsetext: ""
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
expectMatch(
|
||||
'$1 ${1/(?:(wat)|^.*$)$/(?1::hey)/}',
|
||||
[
|
||||
{index: 1, content: []},
|
||||
" ",
|
||||
{
|
||||
index: 1,
|
||||
content: [],
|
||||
substitution: {
|
||||
find: /(?:(wat)|^.*$)$/,
|
||||
replace: [
|
||||
{
|
||||
backreference: 1,
|
||||
iftext: "",
|
||||
elsetext: ["hey"]
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
expectMatch(
|
||||
'class ${1:${TM_FILENAME/(?:\\A|_)([A-Za-z0-9]+)(?:\\.rb)?/(?2::\\u$1)/g}} < ${2:Application}Controller\n $3\nend',
|
||||
[
|
||||
'class ',
|
||||
{
|
||||
index: 1,
|
||||
content: [
|
||||
{
|
||||
variable: 'TM_FILENAME',
|
||||
substitution: {
|
||||
find: /(?:\A|_)([A-Za-z0-9]+)(?:\.rb)?/g,
|
||||
replace: [
|
||||
{
|
||||
backreference: 2,
|
||||
iftext: '',
|
||||
elsetext: [
|
||||
{escape: 'u'},
|
||||
{backreference: 1}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
' < ',
|
||||
{
|
||||
index: 2,
|
||||
content: ['Application']
|
||||
},
|
||||
'Controller\n ',
|
||||
{index: 3, content : []},
|
||||
'\nend'
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it('recognizes escape characters in if/else syntax', () => {
|
||||
|
||||
expectMatch(
|
||||
'$1 ${1/(?:(wat)|^.*$)$/${1:?hey\\:hey:nah}/}',
|
||||
[
|
||||
{index: 1, content: []},
|
||||
" ",
|
||||
{
|
||||
index: 1,
|
||||
content: [],
|
||||
substitution: {
|
||||
find: /(?:(wat)|^.*$)$/,
|
||||
replace: [
|
||||
{
|
||||
backreference: 1,
|
||||
iftext: "hey:hey",
|
||||
elsetext: "nah"
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
expectMatch(
|
||||
'$1 ${1/(?:(wat)|^.*$)$/${1:?hey:n\\}ah}/}',
|
||||
[
|
||||
{index: 1, content: []},
|
||||
" ",
|
||||
{
|
||||
index: 1,
|
||||
content: [],
|
||||
substitution: {
|
||||
find: /(?:(wat)|^.*$)$/,
|
||||
replace: [
|
||||
{
|
||||
backreference: 1,
|
||||
iftext: "hey",
|
||||
elsetext: "n}ah"
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
});
|
||||
|
||||
|
||||
it('parses nested tabstops', () => {
|
||||
expectMatch(
|
||||
'${1:place${2:hol${3:der}}}',
|
||||
[
|
||||
{
|
||||
index: 1,
|
||||
content: [
|
||||
'place',
|
||||
{index: 2, content: [
|
||||
'hol',
|
||||
{index: 3, content: ['der']}
|
||||
]}
|
||||
]
|
||||
}
|
||||
]
|
||||
);
|
||||
|
||||
expectMatch(
|
||||
'${1:${foo:${1}}}',
|
||||
[
|
||||
{
|
||||
index: 1,
|
||||
content: [
|
||||
{
|
||||
variable: 'foo',
|
||||
content: [
|
||||
{
|
||||
index: 1,
|
||||
content: []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it("breaks a snippet body into lines, with each line containing tab stops at the appropriate position", () => {
|
||||
const bodyTree = BodyParser.parse(`\
|
||||
the quick brown $1fox \${2:jumped \${3:over}
|
||||
}the \${4:lazy} dog\
|
||||
`
|
||||
);
|
||||
|
||||
expect(bodyTree).toEqual([
|
||||
"the quick brown ",
|
||||
{index: 1, content: []},
|
||||
"fox ",
|
||||
{
|
||||
index: 2,
|
||||
content: [
|
||||
"jumped ",
|
||||
{index: 3, content: ["over"]},
|
||||
"\n"
|
||||
],
|
||||
},
|
||||
"the ",
|
||||
{index: 4, content: ["lazy"]},
|
||||
" dog"
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
it('handles a snippet with a transformed variable', () => {
|
||||
expectMatch(
|
||||
'module ${1:ActiveRecord::${TM_FILENAME/(?:\\A|_)([A-Za-z0-9]+)(?:\\.rb)?/\\u$1/g}}',
|
||||
[
|
||||
'module ',
|
||||
{
|
||||
index: 1,
|
||||
content: [
|
||||
'ActiveRecord::',
|
||||
{
|
||||
variable: 'TM_FILENAME',
|
||||
substitution: {
|
||||
find: /(?:\A|_)([A-Za-z0-9]+)(?:\.rb)?/g,
|
||||
replace: [
|
||||
{escape: 'u'},
|
||||
{backreference: 1}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it("skips escaped tabstops", () => {
|
||||
const bodyTree = BodyParser.parse("snippet $1 escaped \\$2 \\\\$3");
|
||||
expect(bodyTree).toEqual([
|
||||
"snippet ",
|
||||
{
|
||||
index: 1,
|
||||
content: []
|
||||
},
|
||||
" escaped $2 \\",
|
||||
{
|
||||
index: 3,
|
||||
content: []
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("includes escaped right-braces", () => {
|
||||
const bodyTree = BodyParser.parse("snippet ${1:{\\}}");
|
||||
expect(bodyTree).toEqual([
|
||||
"snippet ",
|
||||
{
|
||||
index: 1,
|
||||
content: ["{}"]
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses a snippet with transformations", () => {
|
||||
const bodyTree = BodyParser.parse("<${1:p}>$0</${1/f/F/}>");
|
||||
expect(bodyTree).toEqual([
|
||||
'<',
|
||||
{index: 1, content: ['p']},
|
||||
'>',
|
||||
{index: 0, content: []},
|
||||
'</',
|
||||
{index: 1, content: [], substitution: {find: /f/, replace: ['F']}},
|
||||
'>'
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses a snippet with transformations and a global flag", () => {
|
||||
const bodyTree = BodyParser.parse("<${1:p}>$0</${1/f/F/g}>");
|
||||
expect(bodyTree).toEqual([
|
||||
'<',
|
||||
{index: 1, content: ['p']},
|
||||
'>',
|
||||
{index: 0, content: []},
|
||||
'</',
|
||||
{index: 1, content: [], substitution: {find: /f/g, replace: ['F']}},
|
||||
'>'
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses a snippet with multiple tab stops with transformations", () => {
|
||||
const bodyTree = BodyParser.parse("${1:placeholder} ${1/(.)/\\u$1/g} $1 ${2:ANOTHER} ${2/^(.*)$/\\L$1/} $2");
|
||||
expect(bodyTree).toEqual([
|
||||
{index: 1, content: ['placeholder']},
|
||||
' ',
|
||||
{
|
||||
index: 1,
|
||||
content: [],
|
||||
substitution: {
|
||||
find: /(.)/g,
|
||||
replace: [
|
||||
{escape: 'u'},
|
||||
{backreference: 1}
|
||||
]
|
||||
}
|
||||
},
|
||||
' ',
|
||||
{index: 1, content: []},
|
||||
' ',
|
||||
{index: 2, content: ['ANOTHER']},
|
||||
' ',
|
||||
{
|
||||
index: 2,
|
||||
content: [],
|
||||
substitution: {
|
||||
find: /^(.*)$/,
|
||||
replace: [
|
||||
{escape: 'L'},
|
||||
{backreference: 1}
|
||||
]
|
||||
}
|
||||
},
|
||||
' ',
|
||||
{index: 2, content: []},
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
it("parses a snippet with transformations and mirrors", () => {
|
||||
const bodyTree = BodyParser.parse("${1:placeholder}\n${1/(.)/\\u$1/g}\n$1");
|
||||
expect(bodyTree).toEqual([
|
||||
{index: 1, content: ['placeholder']},
|
||||
'\n',
|
||||
{
|
||||
index: 1,
|
||||
content: [],
|
||||
substitution: {
|
||||
find: /(.)/g,
|
||||
replace: [
|
||||
{escape: 'u'},
|
||||
{backreference: 1}
|
||||
]
|
||||
}
|
||||
},
|
||||
'\n',
|
||||
{index: 1, content: []}
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses a snippet with a format string and case-control flags", () => {
|
||||
const bodyTree = BodyParser.parse("<${1:p}>$0</${1/(.)(.*)/\\u$1$2/g}>");
|
||||
expect(bodyTree).toEqual([
|
||||
'<',
|
||||
{index: 1, content: ['p']},
|
||||
'>',
|
||||
{index: 0, content: []},
|
||||
'</',
|
||||
{
|
||||
index: 1,
|
||||
content: [],
|
||||
substitution: {
|
||||
find: /(.)(.*)/g,
|
||||
replace: [
|
||||
{escape: 'u'},
|
||||
{backreference: 1},
|
||||
{backreference: 2}
|
||||
]
|
||||
}
|
||||
},
|
||||
'>'
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses a snippet with an escaped forward slash in a transform", () => {
|
||||
// Annoyingly, a forward slash needs to be double-backslashed just like the
|
||||
// other escapes.
|
||||
const bodyTree = BodyParser.parse("<${1:p}>$0</${1/(.)\\/(.*)/\\u$1$2/g}>");
|
||||
expect(bodyTree).toEqual([
|
||||
'<',
|
||||
{index: 1, content: ['p']},
|
||||
'>',
|
||||
{index: 0, content: []},
|
||||
'</',
|
||||
{
|
||||
index: 1,
|
||||
content: [],
|
||||
substitution: {
|
||||
find: /(.)\/(.*)/g,
|
||||
replace: [
|
||||
{escape: 'u'},
|
||||
{backreference: 1},
|
||||
{backreference: 2}
|
||||
]
|
||||
}
|
||||
},
|
||||
'>'
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses a snippet with a placeholder that mirrors another tab stop's content", () => {
|
||||
const bodyTree = BodyParser.parse("$4console.${3:log}('${2:$1}', $1);$0");
|
||||
expect(bodyTree).toEqual([
|
||||
{index: 4, content: []},
|
||||
'console.',
|
||||
{index: 3, content: ['log']},
|
||||
'(\'',
|
||||
{
|
||||
index: 2, content: [
|
||||
{index: 1, content: []}
|
||||
]
|
||||
},
|
||||
'\', ',
|
||||
{index: 1, content: []},
|
||||
');',
|
||||
{index: 0, content: []}
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses a snippet with a placeholder that mixes text and tab stop references", () => {
|
||||
const bodyTree = BodyParser.parse("$4console.${3:log}('${2:uh $1}', $1);$0");
|
||||
expect(bodyTree).toEqual([
|
||||
{index: 4, content: []},
|
||||
'console.',
|
||||
{index: 3, content: ['log']},
|
||||
'(\'',
|
||||
{
|
||||
index: 2, content: [
|
||||
'uh ',
|
||||
{index: 1, content: []}
|
||||
]
|
||||
},
|
||||
'\', ',
|
||||
{index: 1, content: []},
|
||||
');',
|
||||
{index: 0, content: []}
|
||||
]);
|
||||
});
|
||||
});
|
1
packages/snippets/spec/fixtures/package-with-broken-snippets/snippets/.hidden-file
vendored
Normal file
1
packages/snippets/spec/fixtures/package-with-broken-snippets/snippets/.hidden-file
vendored
Normal file
@ -0,0 +1 @@
|
||||
I am hidden so I shouldn't be loaded
|
1
packages/snippets/spec/fixtures/package-with-broken-snippets/snippets/invalid.json
vendored
Normal file
1
packages/snippets/spec/fixtures/package-with-broken-snippets/snippets/invalid.json
vendored
Normal file
@ -0,0 +1 @@
|
||||
I am not a valid JSON file but that shouldn't cause a crisis
|
1
packages/snippets/spec/fixtures/package-with-snippets/snippets/.hidden-file
vendored
Normal file
1
packages/snippets/spec/fixtures/package-with-snippets/snippets/.hidden-file
vendored
Normal file
@ -0,0 +1 @@
|
||||
This is a hidden file. Don't even try to load it as a snippet
|
1
packages/snippets/spec/fixtures/package-with-snippets/snippets/junk-file
vendored
Normal file
1
packages/snippets/spec/fixtures/package-with-snippets/snippets/junk-file
vendored
Normal file
@ -0,0 +1 @@
|
||||
This file isn't CSON, but shouldn't be a big deal
|
31
packages/snippets/spec/fixtures/package-with-snippets/snippets/test.cson
vendored
Normal file
31
packages/snippets/spec/fixtures/package-with-snippets/snippets/test.cson
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
".test":
|
||||
"Test Snippet":
|
||||
prefix: "test"
|
||||
body: "testing 123"
|
||||
"Test Snippet With Description":
|
||||
prefix: "testd"
|
||||
body: "testing 456"
|
||||
description: "a description"
|
||||
descriptionMoreURL: "http://google.com"
|
||||
"Test Snippet With A Label On The Left":
|
||||
prefix: "testlabelleft"
|
||||
body: "testing 456"
|
||||
leftLabel: "a label"
|
||||
"Test Snippet With HTML Labels":
|
||||
prefix: "testhtmllabels"
|
||||
body: "testing 456"
|
||||
leftLabelHTML: "<span style=\"color:red\">Label</span>"
|
||||
rightLabelHTML: "<span style=\"color:white\">Label</span>"
|
||||
|
||||
".package-with-snippets-unique-scope":
|
||||
"Test Snippet":
|
||||
prefix: "test"
|
||||
body: "testing 123"
|
||||
|
||||
".source.js":
|
||||
"Overrides a core package's snippet":
|
||||
prefix: "log"
|
||||
body: "from-a-community-package"
|
||||
"Maps to a command":
|
||||
body: 'lorem ipsum $0 dolor sit amet'
|
||||
command: 'test-command-name'
|
13
packages/snippets/spec/fixtures/sample.js
vendored
Normal file
13
packages/snippets/spec/fixtures/sample.js
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
var quicksort = function () {
|
||||
var sort = function(items) {
|
||||
if (items.length <= 1) return items;
|
||||
var pivot = items.shift(), current, left = [], right = [];
|
||||
while(items.length > 0) {
|
||||
current = items.shift();
|
||||
current < pivot ? left.push(current) : right.push(current);
|
||||
}
|
||||
return sort(left).concat(pivot).concat(sort(right));
|
||||
};
|
||||
|
||||
return sort(Array.apply(this, arguments));
|
||||
};
|
134
packages/snippets/spec/insertion-spec.js
Normal file
134
packages/snippets/spec/insertion-spec.js
Normal file
@ -0,0 +1,134 @@
|
||||
const Insertion = require('../lib/insertion')
|
||||
const { Range } = require('atom')
|
||||
|
||||
const range = new Range(0, 0)
|
||||
|
||||
describe('Insertion', () => {
|
||||
it('returns what it was given when it has no substitution', () => {
|
||||
let insertion = new Insertion({
|
||||
range,
|
||||
substitution: undefined
|
||||
})
|
||||
let transformed = insertion.transform('foo!')
|
||||
|
||||
expect(transformed).toEqual('foo!')
|
||||
})
|
||||
|
||||
it('transforms what it was given when it has a regex transformation', () => {
|
||||
let insertion = new Insertion({
|
||||
range,
|
||||
substitution: {
|
||||
find: /foo/g,
|
||||
replace: ['bar']
|
||||
}
|
||||
})
|
||||
let transformed = insertion.transform('foo!')
|
||||
|
||||
expect(transformed).toEqual('bar!')
|
||||
})
|
||||
|
||||
it('transforms the case of the next character when encountering a \\u or \\l flag', () => {
|
||||
let uInsertion = new Insertion({
|
||||
range,
|
||||
substitution: {
|
||||
find: /(.)(.)(.*)/g,
|
||||
replace: [
|
||||
{ backreference: 1 },
|
||||
{ escape: 'u' },
|
||||
{ backreference: 2 },
|
||||
{ backreference: 3 }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
expect(uInsertion.transform('foo!')).toEqual('fOo!')
|
||||
expect(uInsertion.transform('fOo!')).toEqual('fOo!')
|
||||
expect(uInsertion.transform('FOO!')).toEqual('FOO!')
|
||||
|
||||
let lInsertion = new Insertion({
|
||||
range,
|
||||
substitution: {
|
||||
find: /(.{2})(.)(.*)/g,
|
||||
replace: [
|
||||
{ backreference: 1 },
|
||||
{ escape: 'l' },
|
||||
{ backreference: 2 },
|
||||
{ backreference: 3 }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
expect(lInsertion.transform('FOO!')).toEqual('FOo!')
|
||||
expect(lInsertion.transform('FOo!')).toEqual('FOo!')
|
||||
expect(lInsertion.transform('FoO!')).toEqual('Foo!')
|
||||
expect(lInsertion.transform('foo!')).toEqual('foo!')
|
||||
})
|
||||
|
||||
it('transforms the case of all remaining characters when encountering a \\U or \\L flag, up until it sees a \\E flag', () => {
|
||||
let uInsertion = new Insertion({
|
||||
range,
|
||||
substitution: {
|
||||
find: /(.)(.*)/,
|
||||
replace: [
|
||||
{ backreference: 1 },
|
||||
{ escape: 'U' },
|
||||
{ backreference: 2 }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
expect(uInsertion.transform('lorem ipsum!')).toEqual('lOREM IPSUM!')
|
||||
expect(uInsertion.transform('lOREM IPSUM!')).toEqual('lOREM IPSUM!')
|
||||
expect(uInsertion.transform('LOREM IPSUM!')).toEqual('LOREM IPSUM!')
|
||||
|
||||
let ueInsertion = new Insertion({
|
||||
range,
|
||||
substitution: {
|
||||
find: /(.)(.{3})(.*)/,
|
||||
replace: [
|
||||
{ backreference: 1 },
|
||||
{ escape: 'U' },
|
||||
{ backreference: 2 },
|
||||
{ escape: 'E' },
|
||||
{ backreference: 3 }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
expect(ueInsertion.transform('lorem ipsum!')).toEqual('lOREm ipsum!')
|
||||
expect(ueInsertion.transform('lOREm ipsum!')).toEqual('lOREm ipsum!')
|
||||
expect(ueInsertion.transform('LOREM IPSUM!')).toEqual('LOREM IPSUM!')
|
||||
|
||||
let lInsertion = new Insertion({
|
||||
range,
|
||||
substitution: {
|
||||
find: /(.{4})(.)(.*)/,
|
||||
replace: [
|
||||
{ backreference: 1 },
|
||||
{ escape: 'L' },
|
||||
{ backreference: 2 },
|
||||
'WHAT'
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
expect(lInsertion.transform('LOREM IPSUM!')).toEqual('LOREmwhat')
|
||||
|
||||
let leInsertion = new Insertion({
|
||||
range,
|
||||
substitution: {
|
||||
find: /^([A-Fa-f])(.*)(.)$/,
|
||||
replace: [
|
||||
{ backreference: 1 },
|
||||
{ escape: 'L' },
|
||||
{ backreference: 2 },
|
||||
{ escape: 'E' },
|
||||
{ backreference: 3 }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
expect(leInsertion.transform('LOREM IPSUM!')).toEqual('LOREM IPSUM!')
|
||||
expect(leInsertion.transform('CONSECUETUR')).toEqual('ConsecuetuR')
|
||||
})
|
||||
})
|
345
packages/snippets/spec/snippet-loading-spec.js
Normal file
345
packages/snippets/spec/snippet-loading-spec.js
Normal file
@ -0,0 +1,345 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const temp = require('temp').track();
|
||||
|
||||
describe("Snippet Loading", () => {
|
||||
let configDirPath, snippetsService;
|
||||
|
||||
beforeEach(() => {
|
||||
configDirPath = temp.mkdirSync('atom-config-dir-');
|
||||
spyOn(atom, 'getConfigDirPath').andReturn(configDirPath);
|
||||
|
||||
spyOn(console, 'warn');
|
||||
if (atom.notifications != null) { spyOn(atom.notifications, 'addError'); }
|
||||
|
||||
spyOn(atom.packages, 'getLoadedPackages').andReturn([
|
||||
atom.packages.loadPackage(path.join(__dirname, 'fixtures', 'package-with-snippets')),
|
||||
atom.packages.loadPackage(path.join(__dirname, 'fixtures', 'package-with-broken-snippets')),
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
waitsForPromise(() => Promise.resolve(atom.packages.deactivatePackages('snippets')));
|
||||
runs(() => {
|
||||
jasmine.unspy(atom.packages, 'getLoadedPackages');
|
||||
});
|
||||
});
|
||||
|
||||
const activateSnippetsPackage = () => {
|
||||
waitsForPromise(() => atom.packages.activatePackage("snippets").then(({mainModule}) => {
|
||||
snippetsService = mainModule.provideSnippets();
|
||||
mainModule.loaded = false;
|
||||
}));
|
||||
|
||||
waitsFor("all snippets to load", 3000, () => snippetsService.bundledSnippetsLoaded());
|
||||
};
|
||||
|
||||
it("loads the bundled snippet template snippets", () => {
|
||||
activateSnippetsPackage();
|
||||
|
||||
runs(() => {
|
||||
const jsonSnippet = snippetsService.snippetsForScopes(['.source.json'])['snip'];
|
||||
expect(jsonSnippet.name).toBe('Atom Snippet');
|
||||
expect(jsonSnippet.prefix).toBe('snip');
|
||||
expect(jsonSnippet.body).toContain('"prefix":');
|
||||
expect(jsonSnippet.body).toContain('"body":');
|
||||
expect(jsonSnippet.tabStopList.length).toBeGreaterThan(0);
|
||||
|
||||
const csonSnippet = snippetsService.snippetsForScopes(['.source.coffee'])['snip'];
|
||||
expect(csonSnippet.name).toBe('Atom Snippet');
|
||||
expect(csonSnippet.prefix).toBe('snip');
|
||||
expect(csonSnippet.body).toContain("'prefix':");
|
||||
expect(csonSnippet.body).toContain("'body':");
|
||||
expect(csonSnippet.tabStopList.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("loads non-hidden snippet files from atom packages with snippets directories", () => {
|
||||
activateSnippetsPackage();
|
||||
|
||||
runs(() => {
|
||||
let snippet = snippetsService.snippetsForScopes(['.test'])['test'];
|
||||
expect(snippet.prefix).toBe('test');
|
||||
expect(snippet.body).toBe('testing 123');
|
||||
|
||||
snippet = snippetsService.snippetsForScopes(['.test'])['testd'];
|
||||
expect(snippet.prefix).toBe('testd');
|
||||
expect(snippet.body).toBe('testing 456');
|
||||
expect(snippet.description).toBe('a description');
|
||||
expect(snippet.descriptionMoreURL).toBe('http://google.com');
|
||||
|
||||
snippet = snippetsService.snippetsForScopes(['.test'])['testlabelleft'];
|
||||
expect(snippet.prefix).toBe('testlabelleft');
|
||||
expect(snippet.body).toBe('testing 456');
|
||||
expect(snippet.leftLabel).toBe('a label');
|
||||
|
||||
snippet = snippetsService.snippetsForScopes(['.test'])['testhtmllabels'];
|
||||
expect(snippet.prefix).toBe('testhtmllabels');
|
||||
expect(snippet.body).toBe('testing 456');
|
||||
expect(snippet.leftLabelHTML).toBe('<span style=\"color:red\">Label</span>');
|
||||
expect(snippet.rightLabelHTML).toBe('<span style=\"color:white\">Label</span>');
|
||||
});
|
||||
});
|
||||
|
||||
it("registers a command if a package snippet defines one", () => {
|
||||
waitsForPromise(() => {
|
||||
return atom.packages.activatePackage("snippets").then(
|
||||
({mainModule}) => {
|
||||
return new Promise((resolve) => {
|
||||
mainModule.onDidLoadSnippets(resolve);
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
runs(() => {
|
||||
expect(
|
||||
'package-with-snippets:test-command-name' in atom.commands.registeredCommands
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("logs a warning if package snippets files cannot be parsed", () => {
|
||||
activateSnippetsPackage();
|
||||
|
||||
runs(() => {
|
||||
// Warn about invalid-file, but don't even try to parse a hidden file
|
||||
expect(console.warn.calls.length).toBeGreaterThan(0);
|
||||
expect(console.warn.mostRecentCall.args[0]).toMatch(/Error reading.*package-with-broken-snippets/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("::loadPackageSnippets(callback)", () => {
|
||||
const jsPackage = () => {
|
||||
const pack = atom.packages.loadPackage('language-javascript')
|
||||
pack.path = path.join(
|
||||
atom.getLoadSettings().resourcePath,
|
||||
'node_modules', 'language-javascript'
|
||||
)
|
||||
return pack
|
||||
}
|
||||
|
||||
beforeEach(() => { // simulate a list of packages where the javascript core package is returned at the end
|
||||
atom.packages.getLoadedPackages.andReturn([
|
||||
atom.packages.loadPackage(path.join(__dirname, 'fixtures', 'package-with-snippets')),
|
||||
jsPackage()
|
||||
]);
|
||||
});
|
||||
|
||||
// NOTE: This spec will fail if you're hacking on the Pulsar source code
|
||||
// with `ATOM_DEV_RESOURCE_PATH`. Just make sure it passes in CI and you'll
|
||||
// be fine.
|
||||
it("allows other packages to override core packages' snippets", () => {
|
||||
waitsForPromise(() => atom.packages.activatePackage("language-javascript"));
|
||||
|
||||
activateSnippetsPackage();
|
||||
|
||||
runs(() => {
|
||||
const snippet = snippetsService.snippetsForScopes(['.source.js'])['log'];
|
||||
expect(snippet.body).toBe("from-a-community-package");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("::onDidLoadSnippets(callback)", () => {
|
||||
it("invokes listeners when all snippets are loaded", () => {
|
||||
let loadedCallback = null;
|
||||
|
||||
waitsFor("package to activate", done => atom.packages.activatePackage("snippets").then(({mainModule}) => {
|
||||
mainModule.onDidLoadSnippets(loadedCallback = jasmine.createSpy('onDidLoadSnippets callback'));
|
||||
done();
|
||||
}));
|
||||
|
||||
waitsFor("onDidLoad callback to be called", () => loadedCallback.callCount > 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when ~/.atom/snippets.json exists", () => {
|
||||
beforeEach(() => {
|
||||
fs.mkdirSync(configDirPath, {recursive: true});
|
||||
fs.writeFileSync(path.join(configDirPath, 'snippets.json'), `\
|
||||
{
|
||||
".foo": {
|
||||
"foo snippet": {
|
||||
"prefix": "foo",
|
||||
"body": "bar1"
|
||||
}
|
||||
}
|
||||
}\
|
||||
`
|
||||
);
|
||||
activateSnippetsPackage();
|
||||
});
|
||||
|
||||
it("loads the snippets from that file", () => {
|
||||
let snippet = null;
|
||||
|
||||
waitsFor(() => snippet = snippetsService.snippetsForScopes(['.foo'])['foo']);
|
||||
|
||||
runs(() => {
|
||||
expect(snippet.name).toBe('foo snippet');
|
||||
expect(snippet.prefix).toBe("foo");
|
||||
expect(snippet.body).toBe("bar1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("when that file changes", () => {
|
||||
it("reloads the snippets", () => {
|
||||
fs.mkdirSync(configDirPath, {recursive: true});
|
||||
fs.writeFileSync(path.join(configDirPath, 'snippets.json'), `\
|
||||
{
|
||||
".foo": {
|
||||
"foo snippet": {
|
||||
"prefix": "foo",
|
||||
"body": "bar2"
|
||||
}
|
||||
}
|
||||
}\
|
||||
`
|
||||
);
|
||||
|
||||
waitsFor("snippets to be changed", () => {
|
||||
const snippet = snippetsService.snippetsForScopes(['.foo'])['foo'];
|
||||
return snippet && snippet.body === 'bar2';
|
||||
});
|
||||
|
||||
runs(() => {
|
||||
fs.mkdirSync(configDirPath, {recursive: true});
|
||||
fs.writeFileSync(path.join(configDirPath, 'snippets.json'), "");
|
||||
});
|
||||
|
||||
waitsFor("snippets to be removed", () => !snippetsService.snippetsForScopes(['.foo'])['foo']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when ~/.atom/snippets.cson exists", () => {
|
||||
beforeEach(() => {
|
||||
fs.mkdirSync(configDirPath, {recursive: true});
|
||||
fs.writeFileSync(path.join(configDirPath, 'snippets.cson'), `\
|
||||
".foo":
|
||||
"foo snippet":
|
||||
"prefix": "foo"
|
||||
"body": "bar1"\
|
||||
`
|
||||
);
|
||||
activateSnippetsPackage();
|
||||
});
|
||||
|
||||
it("loads the snippets from that file", () => {
|
||||
let snippet = null;
|
||||
|
||||
waitsFor(() => snippet = snippetsService.snippetsForScopes(['.foo'])['foo']);
|
||||
|
||||
runs(() => {
|
||||
expect(snippet.name).toBe('foo snippet');
|
||||
expect(snippet.prefix).toBe("foo");
|
||||
expect(snippet.body).toBe("bar1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("when that file changes", () => {
|
||||
it("reloads the snippets", () => {
|
||||
fs.mkdirSync(configDirPath, {recursive: true});
|
||||
fs.writeFileSync(path.join(configDirPath, 'snippets.cson'), `\
|
||||
".foo":
|
||||
"foo snippet":
|
||||
"prefix": "foo"
|
||||
"body": "bar2"\
|
||||
`
|
||||
);
|
||||
|
||||
waitsFor("snippets to be changed", () => {
|
||||
const snippet = snippetsService.snippetsForScopes(['.foo'])['foo'];
|
||||
return snippet && snippet.body === 'bar2';
|
||||
});
|
||||
|
||||
runs(() => {
|
||||
fs.mkdirSync(configDirPath, {recursive: true});
|
||||
fs.writeFileSync(path.join(configDirPath, 'snippets.cson'), "");
|
||||
});
|
||||
|
||||
waitsFor("snippets to be removed", () => {
|
||||
const snippet = snippetsService.snippetsForScopes(['.foo'])['foo'];
|
||||
return snippet == null;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("notifies the user when the user snippets file cannot be loaded", () => {
|
||||
fs.writeFileSync(path.join(configDirPath, 'snippets.cson'), '".junk":::');
|
||||
|
||||
activateSnippetsPackage();
|
||||
|
||||
runs(() => {
|
||||
expect(console.warn).toHaveBeenCalled();
|
||||
if (atom.notifications != null) {
|
||||
expect(atom.notifications.addError).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("packages-with-snippets-disabled feature", () => {
|
||||
it("disables no snippets if the config option is empty", () => {
|
||||
const originalConfig = atom.config.get('core.packagesWithSnippetsDisabled');
|
||||
atom.config.set('core.packagesWithSnippetsDisabled', []);
|
||||
|
||||
activateSnippetsPackage();
|
||||
runs(() => {
|
||||
const snippets = snippetsService.snippetsForScopes(['.package-with-snippets-unique-scope']);
|
||||
expect(Object.keys(snippets).length).toBe(1);
|
||||
atom.config.set('core.packagesWithSnippetsDisabled', originalConfig);
|
||||
});
|
||||
});
|
||||
|
||||
it("still includes a disabled package's snippets in the list of unparsed snippets", () => {
|
||||
let originalConfig = atom.config.get('core.packagesWithSnippetsDisabled');
|
||||
atom.config.set('core.packagesWithSnippetsDisabled', []);
|
||||
|
||||
activateSnippetsPackage();
|
||||
runs(() => {
|
||||
atom.config.set('core.packagesWithSnippetsDisabled', ['package-with-snippets']);
|
||||
const allSnippets = snippetsService.getUnparsedSnippets();
|
||||
const scopedSnippet = allSnippets.find(s => s.selectorString === '.package-with-snippets-unique-scope');
|
||||
expect(scopedSnippet).not.toBe(undefined);
|
||||
atom.config.set('core.packagesWithSnippetsDisabled', originalConfig);
|
||||
});
|
||||
});
|
||||
|
||||
it("never loads a package's snippets when that package is disabled in config", () => {
|
||||
const originalConfig = atom.config.get('core.packagesWithSnippetsDisabled');
|
||||
atom.config.set('core.packagesWithSnippetsDisabled', ['package-with-snippets']);
|
||||
|
||||
activateSnippetsPackage();
|
||||
runs(() => {
|
||||
const snippets = snippetsService.snippetsForScopes(['.package-with-snippets-unique-scope']);
|
||||
expect(Object.keys(snippets).length).toBe(0);
|
||||
atom.config.set('core.packagesWithSnippetsDisabled', originalConfig);
|
||||
});
|
||||
});
|
||||
|
||||
it("unloads and/or reloads snippets from a package if the config option is changed after activation", () => {
|
||||
const originalConfig = atom.config.get('core.packagesWithSnippetsDisabled');
|
||||
atom.config.set('core.packagesWithSnippetsDisabled', []);
|
||||
|
||||
activateSnippetsPackage();
|
||||
runs(() => {
|
||||
let snippets = snippetsService.snippetsForScopes(['.package-with-snippets-unique-scope']);
|
||||
expect(Object.keys(snippets).length).toBe(1);
|
||||
|
||||
// Disable it.
|
||||
atom.config.set('core.packagesWithSnippetsDisabled', ['package-with-snippets']);
|
||||
snippets = snippetsService.snippetsForScopes(['.package-with-snippets-unique-scope']);
|
||||
expect(Object.keys(snippets).length).toBe(0);
|
||||
|
||||
// Re-enable it.
|
||||
atom.config.set('core.packagesWithSnippetsDisabled', []);
|
||||
snippets = snippetsService.snippetsForScopes(['.package-with-snippets-unique-scope']);
|
||||
expect(Object.keys(snippets).length).toBe(1);
|
||||
|
||||
atom.config.set('core.packagesWithSnippetsDisabled', originalConfig);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
2017
packages/snippets/spec/snippets-spec.js
Normal file
2017
packages/snippets/spec/snippets-spec.js
Normal file
File diff suppressed because it is too large
Load Diff
67
packages/snippets/spec/variable-spec.js
Normal file
67
packages/snippets/spec/variable-spec.js
Normal file
@ -0,0 +1,67 @@
|
||||
const Variable = require('../lib/variable');
|
||||
const {Point} = require('atom');
|
||||
|
||||
describe('Variable', () => {
|
||||
|
||||
let fakeCursor = {
|
||||
getCurrentWordBufferRange () { return true; },
|
||||
getBufferRow () { return 9; },
|
||||
};
|
||||
|
||||
let fakeSelectionRange = {
|
||||
isEmpty: () => false
|
||||
};
|
||||
|
||||
let fakeEditor = {
|
||||
getTitle () { return 'foo.rb'; },
|
||||
getPath () { return '/Users/pulsar/code/foo.rb'; },
|
||||
getTextInBufferRange (x) {
|
||||
return x === true ? 'word' : 'this text is selected';
|
||||
},
|
||||
lineTextForBufferRow () {
|
||||
return `this may be considered an entire line for the purposes of variable tests`;
|
||||
}
|
||||
};
|
||||
|
||||
let fakeParams = {editor: fakeEditor, cursor: fakeCursor, selectionRange: fakeSelectionRange};
|
||||
|
||||
it('resolves to the right value', () => {
|
||||
const expected = {
|
||||
'TM_FILENAME': 'foo.rb',
|
||||
'TM_FILENAME_BASE': 'foo',
|
||||
'TM_CURRENT_LINE': `this may be considered an entire line for the purposes of variable tests`,
|
||||
'TM_CURRENT_WORD': 'word',
|
||||
'TM_LINE_INDEX': '9',
|
||||
'TM_LINE_NUMBER': '10',
|
||||
'TM_DIRECTORY': '/Users/pulsar/code',
|
||||
'TM_SELECTED_TEXT': 'this text is selected'
|
||||
};
|
||||
|
||||
for (let variable in expected) {
|
||||
let vrbl = new Variable({variable});
|
||||
expect(
|
||||
vrbl.resolve(fakeParams)
|
||||
).toEqual(expected[variable]);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
it('transforms', () => {
|
||||
let vrbl = new Variable({
|
||||
variable: 'TM_FILENAME',
|
||||
substitution: {
|
||||
find: /(?:^|_)([A-Za-z0-9]+)(?:\.rb)?/g,
|
||||
replace: [
|
||||
{escape: 'u'},
|
||||
{backreference: 1}
|
||||
]
|
||||
},
|
||||
point: new Point(0, 0),
|
||||
snippet: {}
|
||||
});
|
||||
|
||||
expect(
|
||||
vrbl.resolve({editor: fakeEditor})
|
||||
).toEqual('Foo');
|
||||
});
|
||||
});
|
@ -50,25 +50,25 @@ export default class ChangeLogView {
|
||||
<p>Feel free to read our <a href="https://github.com/pulsar-edit/pulsar/blob/master/CHANGELOG.md">Full Change Log</a>.</p>
|
||||
<ul>
|
||||
<li>
|
||||
Added <code>TextEditor::getCommentDelimitersForBufferPosition</code> for retrieving comment delimiter strings appropriate for a given buffer position. This allows us to support three new snippet variables: <code>LINE_COMMENT</code>, <code>BLOCK_COMMENT_START</code>, and <code>BLOCK_COMMENT_END</code>.
|
||||
[markdown-preview] Improve rendering performance in preview panes, especially in documents with lots of fenced code blocks.
|
||||
</li>
|
||||
<li>
|
||||
Added ability to use “simple” transformation flags in snippets (like <code>/upcase</code> and <code>/camelcase</code>) within <code>sed</code>-style snippet transformation replacements.
|
||||
[markdown-preview] GitHub-style Markdown preview now uses up-to-date styles and supports dark mode.
|
||||
</li>
|
||||
<li>
|
||||
Improved TypeScript syntax highlighting of regular expressions, TSX fragments, wildcard export identifiers, namespaced types, and template string punctuation.
|
||||
Pulsar's OS level theme will now change according to the selected editor theme if <code>core.syncWindowThemeWithPulsarTheme</code> is enabled.
|
||||
</li>
|
||||
<li>
|
||||
Replaced our underlying Tree-sitter parser for Markdown files with one that’s more stable.
|
||||
[language-sass] Add SCSS Tree-sitter grammar.
|
||||
</li>
|
||||
<li>
|
||||
Fixed issues in Python with unwanted indentation after type annotations and applying scope names to constructor functions.
|
||||
[language-ruby] Update to latest Tree-sitter Ruby parser.
|
||||
</li>
|
||||
<li>
|
||||
Removed Machine PATH handling for Pulsar on Windows, ensuring to only ever attempt PATH manipulation per user. Added additional safety mechanisms when handling a user's PATH variable.
|
||||
[language-gfm] Make each block-level HTML tag its own injection.
|
||||
</li>
|
||||
<li>
|
||||
Update (Linux) metainfo from downstream Pulsar Flatpak
|
||||
[language-typescript] More highlighting fixes, especially for operators.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
|
@ -1,3 +1,4 @@
|
||||
const dedent = require('dedent');
|
||||
|
||||
describe("Renders Markdown", () => {
|
||||
describe("properly when given no opts", () => {
|
||||
@ -7,6 +8,26 @@ describe("Renders Markdown", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it(`escapes HTML in code blocks properly`, () => {
|
||||
let input = dedent`
|
||||
Lorem ipsum dolor.
|
||||
|
||||
\`\`\`html
|
||||
<p>sit amet</p>
|
||||
\`\`\`
|
||||
`
|
||||
|
||||
let expected = dedent`
|
||||
<p>Lorem ipsum dolor.</p>
|
||||
<pre><code class="language-html"><p>sit amet</p>
|
||||
</code></pre>
|
||||
`
|
||||
|
||||
expect(
|
||||
atom.ui.markdown.render(input).trim()
|
||||
).toBe(expected);
|
||||
})
|
||||
|
||||
describe("transforms links correctly", () => {
|
||||
it("makes no changes to a fqdn link", () => {
|
||||
expect(atom.ui.markdown.render("[Hello World](https://github.com)"))
|
||||
|
@ -428,6 +428,8 @@ module.exports = class Package {
|
||||
methodName = versions[version];
|
||||
if (typeof this.mainModule[methodName] === 'function') {
|
||||
servicesByVersion[version] = this.mainModule[methodName]();
|
||||
} else {
|
||||
console.warn(`Package ${this.name} declares it provides ${name}@${version} but it doesn't expose a function in ${methodName}`)
|
||||
}
|
||||
}
|
||||
this.activationDisposables.add(
|
||||
@ -447,6 +449,8 @@ module.exports = class Package {
|
||||
this.mainModule[methodName].bind(this.mainModule)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
console.warn(`Package ${this.name} declares it consumes ${name}@${version} but it doesn't expose a function in ${methodName}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -249,8 +249,8 @@ function renderMarkdown(content, givenOpts = {}) {
|
||||
|
||||
// Here we can add some simple additions that make code highlighting possible later on,
|
||||
// but doesn't actually preform any code highlighting.
|
||||
md.options.highlight = function(str, lang) {
|
||||
return `<pre><code class="language-${lang}">${str}</code></pre>`;
|
||||
md.options.highlight = function (str, lang) {
|
||||
return `<pre><code class="language-${lang}">${md.utils.escapeHtml(str)}</code></pre>`;
|
||||
};
|
||||
|
||||
// Process disables
|
||||
|
@ -4501,7 +4501,7 @@ class NodeRangeSet {
|
||||
getNodeSpec(node, getChildren) {
|
||||
let { startIndex, endIndex, startPosition, endPosition, id } = node;
|
||||
let result = { startIndex, endIndex, startPosition, endPosition, id };
|
||||
if (node.children && getChildren) {
|
||||
if (getChildren && node.childCount > 0) {
|
||||
result.children = [];
|
||||
for (let child of node.children) {
|
||||
result.children.push(this.getNodeSpec(child, false));
|
||||
|
@ -6654,6 +6654,7 @@ markdown-it@^13.0.2:
|
||||
fs-plus "^3.0.0"
|
||||
github-markdown-css "^5.5.1"
|
||||
marked "5.0.3"
|
||||
morphdom "^2.7.2"
|
||||
underscore-plus "^1.0.0"
|
||||
yaml-front-matter "^4.1.1"
|
||||
|
||||
@ -6972,6 +6973,11 @@ moment@^2.19.3:
|
||||
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108"
|
||||
integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==
|
||||
|
||||
morphdom@^2.7.2:
|
||||
version "2.7.2"
|
||||
resolved "https://registry.yarnpkg.com/morphdom/-/morphdom-2.7.2.tgz#d48a87254f9b3031c0e1ec367736721fbaf22167"
|
||||
integrity sha512-Dqb/lHFyTi7SZpY0a5R4I/0Edo+iPMbaUexsHHsLAByyixCDiLHPHyVoKVmrpL0THcT7V9Cgev9y21TQYq6wQg==
|
||||
|
||||
ms@2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
|
||||
@ -8650,9 +8656,8 @@ smart-buffer@^4.0.2, smart-buffer@^4.2.0:
|
||||
resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae"
|
||||
integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==
|
||||
|
||||
"snippets@github:pulsar-edit/snippets#v1.8.0":
|
||||
"snippets@file:./packages/snippets":
|
||||
version "1.8.0"
|
||||
resolved "https://codeload.github.com/pulsar-edit/snippets/tar.gz/31a21d2d6c7e10756f204c2fbb7bb8d140f0744d"
|
||||
dependencies:
|
||||
async "~0.2.6"
|
||||
atom-select-list "^0.7.0"
|
||||
|
Loading…
Reference in New Issue
Block a user