Merge remote-tracking branch 'origin/master' into clojure-grammar-enhancements

This commit is contained in:
Maurício Szabo 2024-06-03 11:39:58 -03:00
commit 852483a30a
75 changed files with 12687 additions and 218 deletions

View File

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

View File

@ -1,5 +1,5 @@
{
"extends": [ "config:base", ":dependencyDashboardApproval"],
"extends": [ "config:recommended", ":dependencyDashboardApproval"],
"constraints": {
"node": "< 16"
},

View File

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

View File

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

View File

@ -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`.
@ -26,7 +51,7 @@
* Replaced our underlying Tree-sitter parser for Markdown files with one thats more stable.
* Fixed issues in Python with unwanted indentation after type annotations and applying scope names to constructor functions.
* 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.
* Update (Linux) metainfo from downstream Pulsar Flatpak
* Update (Linux) metainfo from downstream Pulsar Flatpak
### Pulsar
- Updated: Update Pulsar's Linux desktop & metainfo mostly from Flatpak [@cat-master21](https://github.com/pulsar-edit/pulsar/pull/935)

View File

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

View File

@ -86,13 +86,15 @@ See [RFC 003](https://github.com/atom/atom/blob/master/docs/rfcs/003-consolidate
| **settings-view** | [`./settings-view`](./settings-view) | |
| **package-generator** | [`./package-generator`](./package-generator) | |
| **pulsar-updater** | [`./pulsar-updater`](./pulsar-updater) | |
| **snippets** | [`pulsar-edit/snippets`][snippets] | |
| **snippets** | [`./snippets`](./snippets) | |
| **solarized-dark-syntax** | [`./solarized-dark-syntax`](./solarized-dark-syntax) | |
| **solarized-light-syntax** | [`./solarized-light-syntax`](./solarized-light-syntax) | |
| **spell-check** | [`./spell-check`](./spell-check) | |
| **status-bar** | [`./status-bar`](./status-bar) | |
| **symbol-provider-ctags** | [`./symbol-provider-ctags`](./symbol-provider-ctags) | |
| **symbol-provider-tree-sitter** | [`./symbol-provider-tree-sitter`](./symbol-provider-tree-sitter) | |
| **styleguide** | [`./styleguide`](./styleguide) | |
| **symbols-view** | [`pulsar-edit/symbols-view`][symbols-view] | |
| **symbols-view** | [`./symbols-view`](./symbols-view) | |
| **tabs** | [`./tabs`](./tabs) | |
| **timecop** | [`./timecop`](./timecop) | |
| **tree-view** | [`./tree-view`](./tree-view) | |
@ -102,5 +104,3 @@ See [RFC 003](https://github.com/atom/atom/blob/master/docs/rfcs/003-consolidate
| **wrap-guide** | [`./wrap-guide`](./wrap-guide) | |
[github]: https://github.com/pulsar-edit/github
[snippets]: https://github.com/pulsar-edit/snippets
[symbols-view]: https://github.com/pulsar-edit/symbols-view

View File

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

View File

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

View 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: ['/*', '*/']

View File

@ -0,0 +1,2 @@
(block) @fold

View 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

View File

@ -0,0 +1,3 @@
"{" @indent
"}" @dedent

View 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

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

View File

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

View 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 CSSs “@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/"

View File

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

View File

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

View File

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

View File

@ -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() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,13 @@
{
"parserOptions": {
"sourceType": "module",
"ecmaVersion": 2022
},
"rules": {
"indent": ["error", 2],
"linebreak-style": ["error", "unix"],
"object-curly-spacing": ["error", "never"],
"space-before-function-paren": ["error", "always"],
"semi": ["error", "never"]
}
}

2
packages/snippets/.gitignore vendored Normal file
View File

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

16
packages/snippets/.pairs Normal file
View File

@ -0,0 +1,16 @@
pairs:
ns: Nathan Sobo; nathan
cj: Corey Johnson; cj
dg: David Graham; dgraham
ks: Kevin Sawicki; kevin
jc: Jerry Cheung; jerry
bl: Brian Lopez; brian
jp: Justin Palmer; justin
gt: Garen Torikian; garen
mc: Matt Colyer; mcolyer
bo: Ben Ogle; benogle
jr: Jason Rudolph; jasonrudolph
jl: Jessica Lord; jlord
email:
domain: github.com
#global: true

View File

@ -0,0 +1 @@
[See how you can contribute](https://github.com/pulsar-edit/.github/blob/main/CONTRIBUTING.md)

208
packages/snippets/README.md Normal file
View File

@ -0,0 +1,208 @@
# Snippets package
Expand snippets matching the current prefix with <kbd>tab</kbd> in Pulsar.
To add your own snippets, select the _Pulsar > Snippets..._ menu option if you're using macOS, or the _File > Snippets..._ menu option if you're using Windows, or the _Edit > Snippets..._ menu option if you are using Linux.
## Snippet Format
Snippets files are stored in a package's `snippets/` folder and also loaded from `~/.pulsar/snippets.cson`. They can be either `.json` or `.cson` file types.
```coffee
'.source.js':
'console.log':
'prefix': 'log'
'command': 'insert-console-log'
'body': 'console.log(${1:"crash"});$2'
```
The outermost keys are the selectors where these snippets should be active, prefixed with a period (`.`) (details below).
The next level of keys are the snippet names. Because this is object notation, each snippet must have a different name.
Under each snippet name is a `body` to insert when the snippet is triggered.
`$` followed by a number are the tabs stops which can be cycled between by pressing <kbd>Tab</kbd> once a snippet has been triggered.
The above example adds a `console.log` snippet to JavaScript files that would expand to:
```js
console.log("crash");
```
The string `"crash"` would be initially selected and pressing tab again would place the cursor after the `;`
A snippet specifies how it can be triggered. Thus it must provide **at least one** of the following keys:
### The prefix key
If a `prefix` is defined, it specifies a string that can trigger the snippet. In the above example, typing `log` (as its own word) and then pressing <kbd>Tab</kbd> would replace `log` with the string `console.log("crash")` as described above.
Prefix completions can be suggested if partially typed thanks to the `autocomplete-snippets` package.
### The command key
If a `command` is defined, it specifies a command name that can trigger the snippet. That command can be invoked from the command palette or mapped to a keyboard shortcut via your `keymap.cson`.
If a package called `some-package` had defined that snippet, it would be available in the keymap as `some-package:insert-console-log`, or in the command palette as **Some Package: Insert Console Log**.
If you defined the `console.log` snippet described above in your own `snippets.cson`, it could be referenced in a keymap file as `snippets:insert-console-log`, or in the command palette as **Snippets: Insert Console Log**.
Invoking the command would insert the snippet at the cursor, replacing any text that may be selected.
Snippet command names must be unique. They cant conflict with each other, nor can they conflict with any other commands that have been defined. If there is such a conflict, youll see an error notification describing the problem.
### Optional parameters
These parameters are meant to provide extra information about your snippet to [autocomplete-plus](https://github.com/atom/autocomplete-plus/wiki/Provider-API).
* `leftLabel` will add text to the left part of the autocomplete results box.
* `leftLabelHTML` will overwrite what's in `leftLabel` and allow you to use a bit of CSS such as `color`.
* `rightLabelHTML`. By default, in the right part of the results box you will see the name of the snippet. When using `rightLabelHTML` the name of the snippet will no longer be displayed, and you will be able to use a bit of CSS.
* `description` will add text to a description box under the autocomplete results list.
* `descriptionMoreURL` URL to the documentation of the snippet.
![autocomplete-description](http://i.imgur.com/cvI2lOq.png)
Example:
```coffee
'.source.js':
'console.log':
'prefix': 'log'
'body': 'console.log(${1:"crash"});$2'
'description': 'Output data to the console'
'rightLabelHTML': '<span style="color:#ff0">JS</span>'
```
### Determining the correct scope for a snippet
The outmost key of a snippet is the “scope” that you want the descendent snippets to be available in. The key should be prefixed with a period (`text.html.basic` → `.text.html.basic`). You can find out the correct scope by opening the Settings (<kbd>cmd-,</kbd> on macOS) and selecting the corresponding *Language [xxx]* package. For example, heres the settings page for `language-html`:
![Screenshot of Language Html settings](https://cloud.githubusercontent.com/assets/1038121/5137632/126beb66-70f2-11e4-839b-bc7e84103f67.png)
If it's difficult to determine the package handling the file type in question (for example, for `.md`-documents), you can use another approach:
1. Put your cursor in a file in which you want the snippet to be available.
2. Open the [Command Palette](https://github.com/pulsar-edit/command-palette)
(<kbd>cmd-shift-p</kbd> or <kbd>ctrl-shift-p</kbd>).
3. Run the `Editor: Log Cursor Scope` command.
This will trigger a notification which will contain a list of scopes. The first scope that's listed is the scope for that language. Here are some examples: `source.coffee`, `text.plain`, `text.html.basic`.
## Snippet syntax
This package supports a subset of the features of TextMate snippets, [documented here](http://manual.macromates.com/en/snippets), as well as most features described in the [LSP specification][lsp] and [supported by VSCode][vscode].
The following features from TextMate snippets are not yet supported:
* Interpolated shell code cant reliably be supported cross-platform, and is probably a bad idea anyway. No other editors that support snippets have adopted this feature, and Pulsar wont either.
The following features from VSCode snippets are not yet supported:
* “Choice” syntax like `${1|one,two,three|}` requires that the autocomplete engine pop up a menu to offer the user a choice between the available placeholder options. This may be supported in the future, but right now Pulsar effectively converts this to `${1:one}`, treating the first choice as a conventional placeholder.
### Variables
Pulsar snippets support all of the variables mentioned in the [LSP specification][lsp], plus many of the variables [supported by VSCode][vscode].
Variables can be referenced with `$`, either without braces (`$CLIPBOARD`) or with braces (`${CLIPBOARD}`). Variables can also have fallback values (`${CLIPBOARD:http://example.com}`), simple flag-based transformations (`${CLIPBOARD:/upcase}`), or `sed`-style transformations (`${CLIPBOARD/ /_/g}`).
One of the most useful is `TM_SELECTED_TEXT`, which represents whatever text was selected when the snippet was invoked. (Naturally, this can only happen when a snippet is invoked via command or key shortcut, rather than by typing in a <kbd>Tab</kbd> trigger.)
Others that can be useful:
* `TM_FILENAME`: The name of the current file (`foo.rb`).
* `TM_FILENAME_BASE`: The name of the current file, but without its extension (`foo`).
* `TM_FILEPATH`: The entire path on disk to the current file.
* `TM_CURRENT_LINE`: The entire current line that the cursor is sitting on.
* `TM_CURRENT_WORD`: The entire word that the cursor is within or adjacent to, as interpreted by `cursor.getCurrentWordBufferRange`.
* `CLIPBOARD`: The current contents of the clipboard.
* `CURRENT_YEAR`, `CURRENT_MONTH`, et cetera: referneces to the current date and time in various formats.
* `LINE_COMMENT`, `BLOCK_COMMENT_START`, `BLOCK_COMMENT_END`: uses the correct comment delimiters for whatever language youre in.
Any variable that has no value — for instance, `TM_FILENAME` on an untitled document, or `LINE_COMMENT` in a CSS file — will resolve to an empty string.
#### Variable transformation flags
Pulsar supports the three flags defined in the [LSP snippets specification][lsp] and two other flags that are [implemented in VSCode][vscode]:
* `/upcase` (`foo` → `FOO`)
* `/downcase` (`BAR` → `bar`)
* `/capitalize` (`lorem ipsum dolor` → `Lorem ipsum dolor`) *(first letter uppercased; rest of input left intact)*
* `/camelcase` (`foo bar` → `fooBar`, `lorem-ipsum.dolor``loremIpsumDolor`)
* `/pascalcase` (`foo bar` → `FooBar`, `lorem-ipsum.dolor``LoremIpsumDolor`)
It also supports two other common transformations:
* `/snakecase` (`foo bar` → `foo_bar`, `lorem-ipsum.dolor``lorem_ipsum_dolor`)
* `/kebabcase` (`foo bar` → `foo-bar`, `lorem-ipsum.dolor``lorem-ipsum-dolor`)
These transformation flags can also be applied on backreferences in `sed`-style replacements for transformed tab stops. Given the following example snippet body…
```
[$1] becomes [${1/(.*)/${1:/upcase}/}]
```
…invoking the snippet and typing `Lorem ipsum dolor` will produce:
```
[Lorem ipsum dolor] becomes [LOREM IPSUM DOLOR]
```
#### Variable caveats
* `WORKSPACE_NAME`, `WORKSPACE_FOLDER`, and `RELATIVE_PATH` all rely on the presence of a root project folder, but a Pulsar project can technically have multiple root folders. While this is rare, it is handled by `snippets` as follows: whichever project path is an ancestor of the currently active file is treated as the project root — or the first one found if multiple roots are ancestors.
* `WORKSPACE_NAME` in VSCode refers to “the name of the opened workspace or folder.” In the former case, this appears to mean bundled projects with a `.code-workspace` file extension — which have no Pulsar equivalent. Instead, `WORKSPACE_NAME` will always refer to the last path component of your projects root directory as defined above.
#### Variables that are not yet supported
Of the variables supported by VSCode, Pulsar does not yet support:
* `UUID` (Will automatically be supported when Pulsar uses a version of Electron that has native `crypto.randomUUID`.)
## Multi-line Snippet Body
You can also use multi-line syntax using `"""` for larger templates:
```coffee
'.source.js':
'if, else if, else':
'prefix': 'ieie'
'body': """
if (${1:true}) {
$2
} else if (${3:false}) {
$4
} else {
$5
}
"""
```
## Escaping Characters
Including a literal closing brace inside the text provided by a snippet's tab stop will close that tab stop early. To prevent that, escape the brace with two backslashes, like so:
```coffee
'.source.js':
'function':
'prefix': 'funct'
'body': """
${1:function () {
statements;
\\}
this line is also included in the snippet tab;
}
"""
```
Likewise, if your snippet includes literal references to `$` or `{`, you may have to escape those with two backslashes as well, depending on the context.
## Multiple snippets for the same scope
Snippets for the same scope must be placed within the same key. See [this section of the Pulsar Flight Manual](https://pulsar-edit.dev/docs/launch-manual/sections/using-pulsar/#configuring-with-cson) for more information.
[lsp]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#variables
[vscode]: https://code.visualstudio.com/docs/editor/userdefinedsnippets#_variables

View File

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

View File

@ -0,0 +1,6 @@
# it's critical that these bindings be loaded after those snippets-1 so they
# are later in the cascade, hence breaking the keymap into 2 files
'atom-text-editor:not([mini])':
'tab': 'snippets:next-tab-stop'
'shift-tab': 'snippets:previous-tab-stop'

View File

@ -0,0 +1,76 @@
const SnippetHistoryProvider = require('./snippet-history-provider')
class EditorStore {
constructor (editor) {
this.editor = editor
this.buffer = this.editor.getBuffer()
this.observer = null
this.checkpoint = null
this.expansions = []
this.existingHistoryProvider = null
}
getExpansions () {
return this.expansions
}
setExpansions (list) {
this.expansions = list
}
clearExpansions () {
this.expansions = []
}
addExpansion (snippetExpansion) {
this.expansions.push(snippetExpansion)
}
observeHistory (delegates) {
let isObservingHistory = this.existingHistoryProvider != null
if (isObservingHistory) {
return
} else {
this.existingHistoryProvider = this.buffer.historyProvider
}
const newProvider = SnippetHistoryProvider(this.existingHistoryProvider, delegates)
this.buffer.setHistoryProvider(newProvider)
}
stopObservingHistory (editor) {
if (this.existingHistoryProvider == null) { return }
this.buffer.setHistoryProvider(this.existingHistoryProvider)
this.existingHistoryProvider = null
}
observe (callback) {
if (this.observer != null) { this.observer.dispose() }
this.observer = this.buffer.onDidChangeText(callback)
}
stopObserving () {
if (this.observer == null) { return false }
this.observer.dispose()
this.observer = null
return true
}
makeCheckpoint () {
const existing = this.checkpoint
if (existing) {
this.buffer.groupChangesSinceCheckpoint(existing)
}
this.checkpoint = this.buffer.createCheckpoint()
}
}
EditorStore.store = new WeakMap()
EditorStore.findOrCreate = function (editor) {
if (!this.store.has(editor)) {
this.store.set(editor, new EditorStore(editor))
}
return this.store.get(editor)
}
module.exports = EditorStore

View File

@ -0,0 +1,13 @@
/** @babel */
import path from 'path'
export function getPackageRoot() {
const {resourcePath} = atom.getLoadSettings()
const currentFileWasRequiredFromSnapshot = !path.isAbsolute(__dirname)
if (currentFileWasRequiredFromSnapshot) {
return path.join(resourcePath, 'node_modules', 'snippets')
} else {
return path.resolve(__dirname, '..')
}
}

View File

@ -0,0 +1,31 @@
const Replacer = require('./replacer')
class Insertion {
constructor ({range, substitution, references}) {
this.range = range
this.substitution = substitution
this.references = references
if (substitution) {
if (substitution.replace === undefined) {
substitution.replace = ''
}
this.replacer = new Replacer(substitution.replace)
}
}
isTransformation () {
return !!this.substitution
}
transform (input) {
let {substitution} = this
if (!substitution) { return input }
this.replacer.resetFlags()
return input.replace(substitution.find, (...args) => {
let result = this.replacer.replace(...args)
return result
})
}
}
module.exports = Insertion

View File

@ -0,0 +1,107 @@
const FLAGS = require('./simple-transformations')
const ESCAPES = {
u: (flags) => {
flags.lowercaseNext = false
flags.uppercaseNext = true
},
l: (flags) => {
flags.uppercaseNext = false
flags.lowercaseNext = true
},
U: (flags) => {
flags.lowercaseAll = false
flags.uppercaseAll = true
},
L: (flags) => {
flags.uppercaseAll = false
flags.lowercaseAll = true
},
E: (flags) => {
flags.uppercaseAll = false
flags.lowercaseAll = false
},
r: (flags, result) => {
result.push('\\r')
},
n: (flags, result) => {
result.push('\\n')
},
$: (flags, result) => {
result.push('$')
}
}
function transformTextWithFlags (str, flags) {
if (flags.uppercaseAll) {
return str.toUpperCase()
} else if (flags.lowercaseAll) {
return str.toLowerCase()
} else if (flags.uppercaseNext) {
flags.uppercaseNext = false
return str.replace(/^./, s => s.toUpperCase())
} else if (flags.lowercaseNext) {
return str.replace(/^./, s => s.toLowerCase())
}
return str
}
// `Replacer` handles shared substitution semantics for tabstop and variable
// transformations.
class Replacer {
constructor (tokens) {
this.tokens = [...tokens]
this.resetFlags()
}
resetFlags () {
this.flags = {
uppercaseAll: false,
lowercaseAll: false,
uppercaseNext: false,
lowercaseNext: false
}
}
replace (...match) {
let result = []
function handleToken (token) {
if (typeof token === 'string') {
result.push(transformTextWithFlags(token, this.flags))
} else if (token.escape) {
ESCAPES[token.escape](this.flags, result)
} else if (token.backreference) {
if (token.transform && (token.transform in FLAGS)) {
let transformed = FLAGS[token.transform](match[token.backreference])
result.push(transformed)
} else {
let {iftext, elsetext} = token
if (iftext != null && elsetext != null) {
// If-else syntax makes choices based on the presence or absence of a
// capture group backreference.
let m = match[token.backreference]
let tokenToHandle = m ? iftext : elsetext
if (Array.isArray(tokenToHandle)) {
result.push(...tokenToHandle.map(handleToken.bind(this)))
} else {
result.push(handleToken.call(this, tokenToHandle))
}
} else {
let transformed = transformTextWithFlags(
match[token.backreference],
this.flags
)
result.push(transformed)
}
}
}
}
this.tokens.forEach(handleToken.bind(this))
return result.join('')
}
}
module.exports = Replacer

View File

@ -0,0 +1,47 @@
// Simple transformation flags that can convert a string in various ways. They
// are specified for variables and for transforming substitution
// backreferences, so we need to use them in two places.
const FLAGS = {
// These are included in the LSP spec.
upcase: value => (value || '').toLocaleUpperCase(),
downcase: value => (value || '').toLocaleLowerCase(),
capitalize: (value) => {
return !value ? '' : (value[0].toLocaleUpperCase() + value.substr(1))
},
// These are supported by VSCode.
pascalcase (value) {
const match = value.match(/[a-z0-9]+/gi)
if (!match) {
return value
}
return match.map(word => {
return word.charAt(0).toUpperCase() + word.substr(1)
}).join('')
},
camelcase (value) {
const match = value.match(/[a-z0-9]+/gi)
if (!match) {
return value
}
return match.map((word, index) => {
if (index === 0) {
return word.charAt(0).toLowerCase() + word.substr(1)
}
return word.charAt(0).toUpperCase() + word.substr(1)
}).join('')
},
// No reason not to implement these also.
snakecase (value) {
let camel = this.camelcase(value)
return camel.replace(/[A-Z]/g, (match) => `_${match.toLowerCase()}`)
},
kebabcase (value) {
let camel = this.camelcase(value)
return camel.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`)
}
}
module.exports = FLAGS

View File

@ -0,0 +1,18 @@
let parser
try {
// When the .pegjs file is stable and you're ready for release, run `npx
// pegjs lib/snippet-body.pegjs` to compile the parser. That way end users
// won't have to pay the cost of runtime evaluation.
parser = require('./snippet-body')
} catch (error) {
// When you're iterating on the parser, rename or delete `snippet-body.js` so
// you can make changes to the .pegjs file and have them reflected after a
// window reload.
const fs = require('fs')
const PEG = require('pegjs')
const grammarSrc = fs.readFileSync(require.resolve('./snippet-body.pegjs'), 'utf8')
parser = PEG.generate(grammarSrc)
}
module.exports = parser

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,231 @@
{
// If you're making changes to this file, be sure to re-compile afterward
// using the instructions in `snippet-body-parser.js`.
function makeInteger(i) {
return parseInt(i.join(''), 10);
}
function coalesce (parts) {
const result = [];
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const ri = result.length - 1;
if (typeof part === 'string' && typeof result[ri] === 'string') {
result[ri] = result[ri] + part;
} else {
result.push(part);
}
}
return result;
}
function unwrap (val) {
let shouldUnwrap = Array.isArray(val) && val.length === 1 && typeof val[0] === 'string';
return shouldUnwrap ? val[0] : val;
}
}
bodyContent = content:(tabstop / choice / variable / text)* { return content; }
innerBodyContent = content:(tabstop / choice / variable / nonCloseBraceText)* { return content; }
tabstop = simpleTabstop / tabstopWithoutPlaceholder / tabstopWithPlaceholder / tabstopWithTransform
simpleTabstop = '$' index:int {
return {index: makeInteger(index), content: []}
}
tabstopWithoutPlaceholder = '${' index:int '}' {
return {index: makeInteger(index), content: []}
}
tabstopWithPlaceholder = '${' index:int ':' content:innerBodyContent '}' {
return {index: makeInteger(index), content: content}
}
tabstopWithTransform = '${' index:int substitution:transform '}' {
return {
index: makeInteger(index),
content: [],
substitution: substitution
}
}
choice = '${' index:int '|' choice:choicecontents '|}' {
// Choice syntax requires an autocompleter to offer the user the options. As
// a fallback, we can take the first option and treat it as a placeholder.
const content = choice.length > 0 ? [choice[0]] : []
return {index: makeInteger(index), choice: choice, content: content}
}
choicecontents = elem:choicetext rest:(',' val:choicetext { return val } )* {
return [elem, ...rest]
}
choicetext = choicetext:(choiceEscaped / [^|,] / barred:('|' &[^}]) { return barred.join('') } )+ {
return choicetext.join('')
}
transform = '/' regex:regexString '/' replace:replace '/' flags:flags {
return {find: new RegExp(regex, flags), replace: replace}
}
regexString = regex:(escaped / [^/])* {
return regex.join('')
}
replace = (format / replacetext)*
format = simpleFormat / formatWithoutPlaceholder / formatWithCaseTransform / formatWithIf / formatWithIfElse / formatWithElse / formatEscape / formatWithIfElseAlt / formatWithIfAlt
simpleFormat = '$' index:int {
return {backreference: makeInteger(index)}
}
formatWithoutPlaceholder = '${' index:int '}' {
return {backreference: makeInteger(index)}
}
formatWithCaseTransform = '${' index:int ':' caseTransform:caseTransform '}' {
return {backreference: makeInteger(index), transform: caseTransform}
}
formatWithIf = '${' index:int ':+' iftext:(ifElseText / '') '}' {
return {backreference: makeInteger(index), iftext: unwrap(iftext), elsetext: ''}
}
formatWithIfAlt = '(?' index:int ':' iftext:(ifTextAlt / '') ')' {
return {backreference: makeInteger(index), iftext: unwrap(iftext), elseText: '' }
}
formatWithElse = '${' index:int (':-' / ':') elsetext:(ifElseText / '') '}' {
return {backreference: makeInteger(index), iftext: '', elsetext: unwrap(elsetext)}
}
// Variable interpolation if-else; conditional clause queries the presence of a
// specific tabstop value.
formatWithIfElse = '${' index:int ':?' iftext:ifText ':' elsetext:(ifElseText / '') '}' {
return {backreference: makeInteger(index), iftext: iftext, elsetext: elsetext}
}
// Substitution if-else; conditional clause tests whether a given regex capture
// group matched anything.
formatWithIfElseAlt = '(?' index:int ':' iftext:(ifTextAlt / '') ':' elsetext:(elseTextAlt / '') ')' {
return {backreference: makeInteger(index), iftext: iftext, elsetext: elsetext}
}
nonColonText = text:('\\:' { return ':' } / escaped / [^:])* {
return text.join('')
}
formatEscape = '\\' flag:[ULulErn] {
return {escape: flag}
}
caseTransform = '/' type:[a-zA-Z]* {
return type.join('')
}
replacetext = replacetext:(!formatEscape char:escaped { return char } / !format char:[^/] { return char })+ {
return replacetext.join('')
}
variable = simpleVariable / variableWithSimpleTransform / variableWithoutPlaceholder / variableWithPlaceholder / variableWithTransform
simpleVariable = '$' name:variableName {
return {variable: name}
}
variableWithoutPlaceholder = '${' name:variableName '}' {
return {variable: name}
}
variableWithPlaceholder = '${' name:variableName ':' content:innerBodyContent '}' {
return {variable: name, content: content}
}
variableWithTransform = '${' name:variableName substitution:transform '}' {
return {variable: name, substitution: substitution}
}
variableWithSimpleTransform = '${' name:variableName ':/' substitutionFlag:substitutionFlag '}' {
return {variable: name, substitution: {flag: substitutionFlag}}
}
variableName = first:[a-zA-Z_] rest:[a-zA-Z_0-9]* {
return first + rest.join('')
}
substitutionFlag = chars:[a-z]+ {
return chars.join('')
}
int = [0-9]+
escaped = '\\' char:. {
switch (char) {
case '$':
case '\\':
case ':':
case '\x7D': // back brace; PEGjs would treat it as the JS scope end though
return char
default:
return '\\' + char
}
}
choiceEscaped = '\\' char:. {
switch (char) {
case '$':
case '\\':
case '\x7D':
case '|':
case ',':
return char
default:
return '\\' + char
}
}
flags = flags:[a-z]* {
return flags.join('')
}
text = text:(escaped / !tabstop !variable !choice char:. { return char })+ {
return text.join('')
}
nonCloseBraceText = text:(escaped / !tabstop !variable !choice char:[^}] { return char })+ {
return text.join('')
}
// Two kinds of format string conditional syntax: the `${` flavor and the `(?`
// flavor.
//
// VSCode supports only the `${` flavor. It's easier to parse because the
// if-result and else-result can only be plain text, as per the specification.
//
// TextMate supports both. `(?` is more powerful, but also harder to parse,
// because it can contain special flags and regex backreferences.
// For the first part of a two-part if-else. Runs until the `:` delimiter.
ifText = text:(escaped / char:[^:] { return char })+ {
return text.join('')
}
// For either the second part of a two-part if-else OR the sole part of a
// one-part if/else. Runs until the `}` that ends the expression.
ifElseText = text:(escaped / char:[^}] { return char })+ {
return text.join('')
}
ifTextAlt = text:(formatEscape / format / escaped / char:[^:] { return char })+ {
return coalesce(text);
}
elseTextAlt = text:(formatEscape / format / escaped / char:[^)] { return char })+ {
return coalesce(text);
}

View File

@ -0,0 +1,496 @@
const {CompositeDisposable, Range, Point} = require('atom')
module.exports = class SnippetExpansion {
constructor (snippet, editor, cursor, snippets, {method} = {}) {
this.settingTabStop = false
this.isIgnoringBufferChanges = false
this.onUndoOrRedo = this.onUndoOrRedo.bind(this)
this.snippet = snippet
this.editor = editor
this.cursor = cursor
this.snippets = snippets
this.subscriptions = new CompositeDisposable
this.selections = [this.cursor.selection]
// Method refers to how the snippet was invoked; known values are `prefix`
// or `command`. If neither is present, then snippet was inserted
// programmatically.
this.method = method
// Holds the `Insertion` instance corresponding to each tab stop marker. We
// don't use the tab stop's own numbering here; we renumber them
// consecutively starting at 0 in the order in which they should be
// visited. So `$1` (if present) will always be at index `0`, and `$0` (if
// present) will always be the last index.
this.insertionsByIndex = []
// Each insertion has a corresponding marker. We keep them in a map so we
// can easily reassociate an insertion with its new marker when we destroy
// its old one.
this.markersForInsertions = new Map()
this.resolutionsForVariables = new Map()
this.markersForVariables = new Map()
// The index of the active tab stop.
this.tabStopIndex = null
// If, say, tab stop 4's placeholder references tab stop 2, then tab stop
// 4's insertion goes into this map as a "related" insertion to tab stop 2.
// We need to keep track of this because tab stop 4's marker will need to
// be replaced while 2 is the active index.
this.relatedInsertionsByIndex = new Map()
const startPosition = this.cursor.selection.getBufferRange().start
let {body, tabStopList} = this.snippet
let tabStops = tabStopList.toArray()
let indent = this.editor.lineTextForBufferRow(startPosition.row).match(/^\s*/)[0]
if (this.snippet.lineCount > 1 && indent) {
// Add proper leading indentation to the snippet
body = body.replace(/\n/g, `\n${indent}`)
tabStops = tabStops.map(tabStop => tabStop.copyWithIndent(indent))
}
this.ignoringBufferChanges(() => {
this.editor.transact(() => {
// Determine what each variable reference will be replaced by
// _before_ we make any changes to the state of the editor. This
// affects $TM_SELECTED_TEXT, $TM_CURRENT_WORD, and others.
this.resolveVariables(startPosition)
// Insert the snippet body at the cursor.
const newRange = this.cursor.selection.insertText(body, {autoIndent: false})
// Mark the range we just inserted. Once we interpolate variables and
// apply transformations, the range may grow, and we need to keep
// track of that so we can normalize tabs later on.
const newRangeMarker = this.getMarkerLayer(this.editor).markBufferRange(newRange, {exclusive: false})
if (this.snippet.tabStopList.length > 0) {
// Listen for cursor changes so we can decide whether to keep the
// snippet active or terminate it.
this.subscriptions.add(
this.cursor.onDidChangePosition(event => this.cursorMoved(event)),
this.cursor.onDidDestroy(() => this.cursorDestroyed())
)
// First we'll add display markers for tab stops and variables.
// Both need these areas to be marked before any expansion happens
// so that they don't lose track of where their slots are.
this.placeTabStopMarkers(startPosition, tabStops)
this.markVariables(startPosition)
// Now we'll expand variables. All markers in the previous step
// were defined with `exclusive: false`, so any that are affected
// by variable expansion will grow if necessary.
this.expandVariables(startPosition)
// Now we'll make the first tab stop active and apply snippet
// transformations for the first time. As part of this process,
// most markers will be converted to `exclusive: true` and adjusted
// as necessary as the user tabs through the snippet.
this.setTabStopIndex(0)
this.applyAllTransformations()
this.snippets.addExpansion(this.editor, this)
} else {
// No tab stops, so we're free to mark and expand variables without
// worrying about the delicate order of operations.
this.markVariables(startPosition)
this.expandVariables(startPosition)
}
// Snippet bodies are written generically and don't know anything
// about the user's indentation settings. So we adjust them after
// expansion.
this.editor.normalizeTabsInBufferRange(newRangeMarker.getBufferRange())
})
})
}
// Set a flag on undo or redo so that we know not to re-apply transforms.
// They're already accounted for in the history.
onUndoOrRedo (isUndo) {
this.isUndoingOrRedoing = true
}
cursorMoved ({oldBufferPosition, newBufferPosition, textChanged}) {
if (this.settingTabStop || textChanged) { return }
const insertionAtCursor = this.insertionsByIndex[this.tabStopIndex].find(insertion => {
let marker = this.markersForInsertions.get(insertion)
return marker.getBufferRange().containsPoint(newBufferPosition)
})
if (insertionAtCursor && !insertionAtCursor.isTransformation()) { return }
this.destroy()
}
cursorDestroyed () {
// The only time a cursor can be destroyed without it ending the snippet is
// if we move from a mirrored tab stop (i.e., multiple cursors) to a
// single-cursor tab stop.
if (!this.settingTabStop) { this.destroy() }
}
textChanged (event) {
if (this.isIgnoringBufferChanges) { return }
// Don't try to alter the buffer if all we're doing is restoring a snapshot
// from history.
if (this.isUndoingOrRedoing) {
this.isUndoingOrRedoing = false
return
}
this.applyTransformations(this.tabStopIndex)
}
ignoringBufferChanges (callback) {
const wasIgnoringBufferChanges = this.isIgnoringBufferChanges
this.isIgnoringBufferChanges = true
callback()
this.isIgnoringBufferChanges = wasIgnoringBufferChanges
}
applyAllTransformations () {
this.editor.transact(() => {
this.insertionsByIndex.forEach((insertion, index) =>
this.applyTransformations(index))
})
}
applyTransformations (tabStopIndex) {
const insertions = [...this.insertionsByIndex[tabStopIndex]]
if (insertions.length === 0) { return }
const primaryInsertion = insertions.shift()
const primaryRange = this.markersForInsertions.get(primaryInsertion).getBufferRange()
const inputText = this.editor.getTextInBufferRange(primaryRange)
this.ignoringBufferChanges(() => {
for (const [index, insertion] of insertions.entries()) {
// Don't transform mirrored tab stops. They have their own cursors, so
// mirroring happens automatically.
if (!insertion.isTransformation()) { continue }
var marker = this.markersForInsertions.get(insertion)
var range = marker.getBufferRange()
var outputText = insertion.transform(inputText)
this.editor.transact(() => this.editor.setTextInBufferRange(range, outputText))
// Manually adjust the marker's range rather than rely on its internal
// heuristics. (We don't have to worry about whether it's been
// invalidated because setting its buffer range implicitly marks it as
// valid again.)
const newRange = new Range(
range.start,
range.start.traverse(new Point(0, outputText.length))
)
marker.setBufferRange(newRange)
}
})
}
resolveVariables (startPosition) {
let params = {
editor: this.editor,
cursor: this.cursor,
selectionRange: this.cursor.selection.getBufferRange(),
method: this.method
}
for (const variable of this.snippet.variables) {
let resolution = variable.resolve(params)
this.resolutionsForVariables.set(variable, resolution)
}
}
markVariables (startPosition) {
// We make two passes here. On the first pass, we create markers for each
// point where a variable will be inserted. On the second pass, we use each
// marker to insert the resolved variable value.
//
// Those points will move around as we insert text into them, so the
// markers are crucial for ensuring we adapt to those changes.
for (const variable of this.snippet.variables) {
const {point} = variable
const marker = this.getMarkerLayer(this.editor).markBufferRange([
startPosition.traverse(point),
startPosition.traverse(point)
], {exclusive: false})
this.markersForVariables.set(variable, marker)
}
}
expandVariables (startPosition) {
this.editor.transact(() => {
for (const variable of this.snippet.variables) {
let marker = this.markersForVariables.get(variable)
let resolution = this.resolutionsForVariables.get(variable)
let range = marker.getBufferRange()
this.editor.setTextInBufferRange(range, resolution)
}
})
}
placeTabStopMarkers (startPosition, tabStops) {
// Tab stops within a snippet refer to one another by their external index
// (1 for $1, 3 for $3, etc.). We respect the order of these tab stops, but
// we renumber them starting at 0 and using consecutive numbers.
//
// Luckily, we don't need to convert between the two numbering systems very
// often. But we do have to build a map from external index to our internal
// index. We do this in a separate loop so that the table is complete
// before we need to consult it in the following loop.
const indexTable = {}
for (let [index, tabStop] of tabStops.entries()) {
indexTable[tabStop.index] = index
}
for (let [index, tabStop] of tabStops.entries()) {
const {insertions} = tabStop
if (!tabStop.isValid()) { continue }
for (const insertion of insertions) {
const {range} = insertion
const {start, end} = range
let references = null
if (insertion.references) {
references = insertion.references.map(external => indexTable[external])
}
// This is our initial pass at marking tab stop regions. In a minute,
// once the first tab stop is made active, we will make some of these
// markers exclusive and some inclusive. But right now we need them all
// to be inclusive, because we want them all to react when we resolve
// snippet variables, and grow if they need to.
const marker = this.getMarkerLayer(this.editor).markBufferRange([
startPosition.traverse(start),
startPosition.traverse(end)
], {exclusive: false})
// Now that we've created these markers, we need to store them in a
// data structure because they'll need to be deleted and re-created
// when their exclusivity changes.
this.markersForInsertions.set(insertion, marker)
if (references) {
// The insertion at tab stop `index` (internal numbering) is related
// to, and affected by, all the tab stops mentioned in `references`
// (internal numbering). We need to make sure we're included in these
// other tab stops' exclusivity changes.
for (let ref of references) {
let relatedInsertions = this.relatedInsertionsByIndex.get(ref) || []
relatedInsertions.push(insertion)
this.relatedInsertionsByIndex.set(ref, relatedInsertions)
}
}
}
this.insertionsByIndex[index] = insertions
}
}
// When two insertion markers are directly adjacent to one another, and the
// cursor is placed right at the border between them, the marker that should
// "claim" the newly typed content will vary based on context.
//
// All else being equal, that content should get added to the marker (if any)
// whose tab stop is active, or else the marker whose tab stop's placeholder
// references an active tab stop. To use the terminology of Atom's
// `DisplayMarker`, all markers related to the active tab stop should be
// "inclusive," and all others should be "exclusive."
//
// Exclusivity cannot be changed after a marker is created. So we need to
// revisit the markers whenever the active tab stop changes, figure out which
// ones need to be touched, and replace them with markers that have the
// settings we need.
//
adjustTabStopMarkers (oldIndex, newIndex) {
// All the insertions belonging to the newly active tab stop (and all
// insertions whose placeholders reference the newly active tab stop)
// should become inclusive.
const insertionsToMakeInclusive = [
...this.insertionsByIndex[newIndex],
...(this.relatedInsertionsByIndex.get(newIndex) || [])
]
// All insertions that are _not_ related to the newly active tab stop
// should become exclusive if they aren't already.
let insertionsToMakeExclusive
if (oldIndex === null) {
// This is the first index to be made active. Since all insertion markers
// were initially created to be inclusive, we need to adjust _all_
// insertion markers that are not related to the new tab stop.
let allInsertions = this.insertionsByIndex.reduce((set, ins) => {
set.push(...ins)
return set
}, [])
insertionsToMakeExclusive = allInsertions.filter(ins => {
return !insertionsToMakeInclusive.includes(ins)
})
} else {
// We are moving from one tab stop to another, so we only need to touch
// the markers related to the tab stop we're departing.
insertionsToMakeExclusive = [
...this.insertionsByIndex[oldIndex],
...(this.relatedInsertionsByIndex.get(oldIndex) || [])
]
}
for (let insertion of insertionsToMakeExclusive) {
this.replaceMarkerForInsertion(insertion, {exclusive: true})
}
for (let insertion of insertionsToMakeInclusive) {
this.replaceMarkerForInsertion(insertion, {exclusive: false})
}
}
replaceMarkerForInsertion (insertion, settings) {
const marker = this.markersForInsertions.get(insertion)
// If the marker is invalid or destroyed, return it as-is. Other methods
// need to know if a marker has been invalidated or destroyed, and we have
// no need to change the settings on such markers anyway.
if (!marker.isValid() || marker.isDestroyed()) {
return marker
}
// Otherwise, create a new marker with an identical range and the specified
// settings.
const range = marker.getBufferRange()
const replacement = this.getMarkerLayer(this.editor).markBufferRange(range, settings)
marker.destroy()
this.markersForInsertions.set(insertion, replacement)
return replacement
}
goToNextTabStop () {
const nextIndex = this.tabStopIndex + 1
if (nextIndex < this.insertionsByIndex.length) {
if (this.setTabStopIndex(nextIndex)) {
return true
} else {
return this.goToNextTabStop()
}
} else {
// The user has tabbed past the last tab stop. If the last tab stop is a
// $0, we shouldn't move the cursor any further.
if (this.snippet.tabStopList.hasEndStop) {
this.destroy()
return false
} else {
const succeeded = this.goToEndOfLastTabStop()
this.destroy()
return succeeded
}
}
}
goToPreviousTabStop () {
if (this.tabStopIndex > 0) { this.setTabStopIndex(this.tabStopIndex - 1) }
}
setTabStopIndex (newIndex) {
const oldIndex = this.tabStopIndex
this.tabStopIndex = newIndex
// Set a flag before moving any selections so that our change handlers know
// that the movements were initiated by us.
this.settingTabStop = true
// Keep track of whether we placed any selections or cursors.
let markerSelected = false
const insertions = this.insertionsByIndex[this.tabStopIndex]
if (insertions.length === 0) { return false }
const ranges = []
this.hasTransforms = false
// Go through the active tab stop's markers to figure out where to place
// cursors and/or selections.
for (const insertion of insertions) {
const marker = this.markersForInsertions.get(insertion)
if (marker.isDestroyed()) { continue }
if (!marker.isValid()) { continue }
if (insertion.isTransformation()) {
// Set a flag for later, but skip transformation insertions because
// they don't get their own cursors.
this.hasTransforms = true
continue
}
ranges.push(marker.getBufferRange())
}
if (ranges.length > 0) {
// We have new selections to apply. Reuse existing selections if
// possible, destroying the unused ones if we already have too many.
for (const selection of this.selections.slice(ranges.length)) { selection.destroy() }
this.selections = this.selections.slice(0, ranges.length)
for (let i = 0; i < ranges.length; i++) {
const range = ranges[i]
if (this.selections[i]) {
this.selections[i].setBufferRange(range)
} else {
const newSelection = this.editor.addSelectionForBufferRange(range)
this.subscriptions.add(newSelection.cursor.onDidChangePosition(event => this.cursorMoved(event)))
this.subscriptions.add(newSelection.cursor.onDidDestroy(() => this.cursorDestroyed()))
this.selections.push(newSelection)
}
}
// We placed at least one selection, so this tab stop was successfully
// set.
markerSelected = true
}
this.settingTabStop = false
// If this snippet has at least one transform, we need to observe changes
// made to the editor so that we can update the transformed tab stops.
if (this.hasTransforms) {
this.snippets.observeEditor(this.editor)
} else {
this.snippets.stopObservingEditor(this.editor)
}
this.adjustTabStopMarkers(oldIndex, newIndex)
return markerSelected
}
goToEndOfLastTabStop () {
const size = this.insertionsByIndex.length
if (size === 0) { return }
const insertions = this.insertionsByIndex[size - 1]
if (insertions.length === 0) { return }
const lastMarker = this.markersForInsertions.get(insertions[insertions.length - 1])
if (lastMarker.isDestroyed()) {
return false
} else {
this.editor.setCursorBufferPosition(lastMarker.getEndBufferPosition())
return true
}
}
destroy () {
this.subscriptions.dispose()
this.getMarkerLayer(this.editor).clear()
this.insertionsByIndex = []
this.relatedInsertionsByIndex.clear()
this.markersForInsertions.clear()
this.resolutionsForVariables.clear()
this.markersForVariables.clear()
this.snippets.stopObservingEditor(this.editor)
this.snippets.clearExpansions(this.editor)
}
getMarkerLayer () {
return this.snippets.findOrCreateMarkerLayer(this.editor)
}
restore (editor) {
this.editor = editor
this.snippets.addExpansion(this.editor, this)
}
}

View File

@ -0,0 +1,27 @@
function wrap (manager, callbacks) {
let klass = new SnippetHistoryProvider(manager)
return new Proxy(manager, {
get (target, name) {
if (name in callbacks) {
callbacks[name]()
}
return name in klass ? klass[name] : target[name]
}
})
}
class SnippetHistoryProvider {
constructor (manager) {
this.manager = manager
}
undo (...args) {
return this.manager.undo(...args)
}
redo (...args) {
return this.manager.redo(...args)
}
}
module.exports = wrap

View File

@ -0,0 +1,109 @@
const {Point, Range} = require('atom')
const TabStopList = require('./tab-stop-list')
const Variable = require('./variable')
function tabStopsReferencedWithinTabStopContent (segment) {
const results = []
for (const item of segment) {
if (item.index) {
results.push(item.index, ...tabStopsReferencedWithinTabStopContent(item.content))
}
}
return new Set(results)
}
module.exports = class Snippet {
constructor (attrs) {
let {
id,
bodyText,
bodyTree,
command,
description,
descriptionMoreURL,
leftLabel,
leftLabelHTML,
name,
prefix,
packageName,
rightLabelHTML,
selector
} = attrs
this.id = id
this.name = name
this.prefix = prefix
this.command = command
this.packageName = packageName
this.bodyText = bodyText
this.description = description
this.descriptionMoreURL = descriptionMoreURL
this.rightLabelHTML = rightLabelHTML
this.leftLabel = leftLabel
this.leftLabelHTML = leftLabelHTML
this.selector = selector
this.variables = []
this.tabStopList = new TabStopList(this)
this.body = this.extractTokens(bodyTree)
if (packageName && command) {
this.commandName = `${packageName}:${command}`
}
}
extractTokens (bodyTree) {
const bodyText = []
let row = 0, column = 0
let extract = bodyTree => {
for (let segment of bodyTree) {
if (segment.index != null) {
// Tabstop.
let {index, content, substitution} = segment
// Ensure tabstop `$0` is always last.
if (index === 0) { index = Infinity }
const start = [row, column]
extract(content)
const referencedTabStops = tabStopsReferencedWithinTabStopContent(content)
const range = new Range(start, [row, column])
const tabStop = this.tabStopList.findOrCreate({
index, snippet: this
})
tabStop.addInsertion({
range,
substitution,
references: [...referencedTabStops]
})
} else if (segment.variable != null) {
// Variable.
let point = new Point(row, column)
this.variables.push(
new Variable({...segment, point, snippet: this})
)
} else if (typeof segment === 'string') {
bodyText.push(segment)
let segmentLines = segment.split('\n')
column += segmentLines.shift().length
let nextLine
while ((nextLine = segmentLines.shift()) != null) {
row += 1
column = nextLine.length
}
}
}
}
extract(bodyTree)
this.lineCount = row + 1
this.insertions = this.tabStopList.getInsertions()
return bodyText.join('')
}
}

View File

@ -0,0 +1,84 @@
/** @babel */
import _ from 'underscore-plus'
import SelectListView from 'atom-select-list'
export default class SnippetsAvailable {
constructor (snippets) {
this.panel = null
this.snippets = snippets
this.selectListView = new SelectListView({
items: [],
filterKeyForItem: (snippet) => snippet.searchText,
elementForItem: (snippet) => {
const li = document.createElement('li')
li.classList.add('two-lines')
const primaryLine = document.createElement('div')
primaryLine.classList.add('primary-line')
primaryLine.textContent = snippet.prefix
li.appendChild(primaryLine)
const secondaryLine = document.createElement('div')
secondaryLine.classList.add('secondary-line')
secondaryLine.textContent = snippet.name
li.appendChild(secondaryLine)
return li
},
didConfirmSelection: (snippet) => {
for (const cursor of this.editor.getCursors()) {
this.snippets.insert(snippet.bodyText, this.editor, cursor)
}
this.cancel()
},
didConfirmEmptySelection: () => {
this.cancel()
},
didCancelSelection: () => {
this.cancel()
}
})
this.selectListView.element.classList.add('available-snippets')
this.element = this.selectListView.element
}
async toggle (editor) {
this.editor = editor
if (this.panel != null) {
this.cancel()
} else {
this.selectListView.reset()
await this.populate()
this.attach()
}
}
cancel () {
this.editor = null
if (this.panel != null) {
this.panel.destroy()
this.panel = null
}
if (this.previouslyFocusedElement) {
this.previouslyFocusedElement.focus()
this.previouslyFocusedElement = null
}
}
populate () {
const snippets = Object.values(this.snippets.getSnippets(this.editor))
for (let snippet of snippets) {
snippet.searchText = _.compact([snippet.prefix, snippet.name]).join(' ')
}
return this.selectListView.update({items: snippets})
}
attach () {
this.previouslyFocusedElement = document.activeElement
this.panel = atom.workspace.addModalPanel({item: this})
this.selectListView.focus()
}
}

View File

@ -0,0 +1,57 @@
'.source.json':
'Atom Snippet':
prefix: 'snip'
body: """
{
"${1:.source.js}": {
"${2:Snippet Name}": {
"prefix": "${3:Snippet Trigger}",
"body": "${4:Hello World!}"
}
}
}$5
"""
'Atom Snippet With No Selector':
prefix: 'snipns'
body: """
"${1:Snippet Name}": {
"prefix": "${2:Snippet Trigger}",
"body": "${3:Hello World!}"
}$4
"""
'Atom Keymap':
prefix: 'key'
body: """
{
"${1:body}": {
"${2:cmd}-${3:i}": "${4:namespace}:${5:event}"
}
}$6
"""
'.source.coffee':
'Atom Snippet':
prefix: 'snip'
body: """
'${1:.source.js}':
'${2:Snippet Name}':
'prefix': '${3:Snippet Trigger}'
'body': '${4:Hello World!}'$5
"""
'Atom Snippet With No Selector':
prefix: 'snipns'
body: """
'${1:Snippet Name}':
'prefix': '${2:Snippet Trigger}'
'body': '${3:Hello World!}'$4
"""
'Atom Keymap':
prefix: 'key'
body: """
'${1:body}':
'${2:cmd}-${3:i}': '${4:namespace}:${5:event}'$6
"""

View File

@ -0,0 +1,936 @@
const path = require('path')
const {Emitter, Disposable, CompositeDisposable, File} = require('atom')
const _ = require('underscore-plus')
const async = require('async')
const CSON = require('season')
const fs = require('fs')
const ScopedPropertyStore = require('scoped-property-store')
const Snippet = require('./snippet')
const SnippetExpansion = require('./snippet-expansion')
const EditorStore = require('./editor-store')
const {getPackageRoot} = require('./helpers')
// TODO: Not sure about validity of numbers in here, but might as well be
// permissive.
const COMMAND_NAME_PATTERN = /^[a-z\d][a-z\d\-]*[a-z\d]$/
function isValidCommandName (commandName) {
return COMMAND_NAME_PATTERN.test(commandName)
}
function showCommandNameConflictNotification (name, commandName, packageName, snippetsPath) {
let remedy
if (packageName === 'builtin') {
// If somehow this happens with a builtin snippet, something crazy is
// happening. But we shouldn't show a notification because there's no
// action for the user to take. Just fail silently.
return
}
if (packageName === 'snippets') {
let extension = snippetsPath.substring(snippetsPath.length - 4)
remedy = `Edit your \`snippets.${extension}\` file to resolve this conflict.`
} else {
remedy = `Contact the maintainer of \`${packageName}\` so they can resolve this conflict.`
}
const message = `Cannot register command \`${commandName}\` for snippet “${name}” because that command name already exists.\n\n${remedy}`
atom.notifications.addError(
`Snippets conflict`,
{
description: message,
dismissable: true
}
)
}
function showInvalidCommandNameNotification (name, commandName) {
const message = `Cannot register \`${commandName}\` for snippet “${name}” because the command name isnt valid. Command names must be all lowercase and use hyphens between words instead of spaces.`
atom.notifications.addError(
`Snippets error`,
{
description: message,
dismissable: true
}
)
}
// When we first run, checking `atom.commands.registeredCommands` is a good way
// of checking whether a command of a certain name already exists. But if we
// register a command and then unregister it (e.g., upon later disabling of a
// package's snippets), the relevant key won't get deleted from
// `registeredCommands`. So if the user re-enables the snippets, we'll
// incorrectly think that the command already exists.
//
// Hence, after the first check, we have to keep track ourselves. At least this
// gives us a place to keep track of individual command disposables.
//
const CommandMonitor = {
map: new Map,
disposables: new Map,
compositeDisposable: new CompositeDisposable,
exists (commandName) {
let {map} = this
if (!map.has(commandName)) {
// If it's missing altogether from the registry, we haven't asked yet.
let value = atom.commands.registeredCommands[commandName]
map.set(commandName, value)
return value
} else {
return map.get(commandName)
}
},
add (commandName, disposable) {
this.map.set(commandName, true)
this.disposables.set(commandName, disposable)
this.compositeDisposable.add(disposable)
},
remove (commandName) {
this.map.set(commandName, false)
let disposable = this.disposables.get(commandName)
if (disposable) { disposable.dispose() }
},
reset () {
this.map.clear()
this.disposables.clear()
this.compositeDisposable.dispose()
}
}
// When we load snippets from packages, we're given a bunch of package paths
// instead of package names. This lets us match the former to the latter.
const PackageNameResolver = {
pathsToNames: new Map,
setup () {
this.pathsToNames.clear()
let meta = atom.packages.getLoadedPackages() || []
for (let {name, path} of meta) {
this.pathsToNames.set(path, name)
}
if (!this._observing) {
atom.packages.onDidLoadPackage(() => this.setup())
atom.packages.onDidUnloadPackage(() => this.setup())
}
this._observing = true
},
find (filePath) {
for (let [packagePath, name] of this.pathsToNames.entries()) {
if (filePath.startsWith(`${packagePath}${path.sep}`)) return name
}
return null
}
}
module.exports = {
activate () {
this.loaded = false
this.userSnippetsPath = null
this.snippetIdCounter = 0
this.snippetsByPackage = new Map
this.parsedSnippetsById = new Map
this.editorMarkerLayers = new WeakMap
this.scopedPropertyStore = new ScopedPropertyStore
// The above ScopedPropertyStore will store the main registry of snippets.
// But we need a separate ScopedPropertyStore for the snippets that come
// from disabled packages. They're isolated so that they're not considered
// as candidates when the user expands a prefix, but we still need the data
// around so that the snippets provided by those packages can be shown in
// the settings view.
this.disabledSnippetsScopedPropertyStore = new ScopedPropertyStore
this.subscriptions = new CompositeDisposable
this.subscriptions.add(atom.workspace.addOpener(uri => {
if (uri === 'atom://.pulsar/snippets') {
return atom.workspace.openTextFile(this.getUserSnippetsPath())
}
}))
PackageNameResolver.setup()
this.loadAll()
this.watchUserSnippets(watchDisposable => {
this.subscriptions.add(watchDisposable)
})
this.subscriptions.add(
atom.config.onDidChange(
'core.packagesWithSnippetsDisabled',
({newValue, oldValue}) => {
this.handleDisabledPackagesDidChange(newValue, oldValue)
}
)
)
const snippets = this
this.subscriptions.add(atom.commands.add('atom-text-editor', {
'snippets:expand' (event) {
const editor = this.getModel()
if (snippets.snippetToExpandUnderCursor(editor)) {
snippets.clearExpansions(editor)
snippets.expandSnippetsUnderCursors(editor)
} else {
event.abortKeyBinding()
}
},
'snippets:next-tab-stop' (event) {
const editor = this.getModel()
if (!snippets.goToNextTabStop(editor)) { event.abortKeyBinding() }
},
'snippets:previous-tab-stop' (event) {
const editor = this.getModel()
if (!snippets.goToPreviousTabStop(editor)) { event.abortKeyBinding() }
},
'snippets:available' (event) {
const editor = this.getModel()
const SnippetsAvailable = require('./snippets-available')
if (snippets.availableSnippetsView == null) {
snippets.availableSnippetsView = new SnippetsAvailable(snippets)
}
snippets.availableSnippetsView.toggle(editor)
}
}))
},
deactivate () {
if (this.emitter != null) {
this.emitter.dispose()
}
this.emitter = null
this.editorSnippetExpansions = null
atom.config.transact(() => this.subscriptions.dispose())
CommandMonitor.reset()
},
getUserSnippetsPath () {
if (this.userSnippetsPath != null) { return this.userSnippetsPath }
this.userSnippetsPath = CSON.resolve(path.join(atom.getConfigDirPath(), 'snippets'))
if (this.userSnippetsPath == null) { this.userSnippetsPath = path.join(atom.getConfigDirPath(), 'snippets.cson') }
return this.userSnippetsPath
},
loadAll () {
this.loadBundledSnippets(bundledSnippets => {
this.loadPackageSnippets(packageSnippets => {
this.loadUserSnippets(userSnippets => {
atom.config.transact(() => {
for (const [filepath, snippetsBySelector] of Object.entries(bundledSnippets)) {
this.add(filepath, snippetsBySelector, 'builtin')
}
for (const [filepath, snippetsBySelector] of Object.entries(packageSnippets)) {
let packageName = PackageNameResolver.find(filepath) || 'snippets'
this.add(filepath, snippetsBySelector, packageName)
}
for (const [filepath, snippetsBySelector] of Object.entries(userSnippets)) {
this.add(filepath, snippetsBySelector, 'snippets')
}
})
this.doneLoading()
})
})
})
},
loadBundledSnippets (callback) {
const bundledSnippetsPath = CSON.resolve(path.join(getPackageRoot(), 'lib', 'snippets'))
this.loadSnippetsFile(bundledSnippetsPath, snippets => {
const snippetsByPath = {}
snippetsByPath[bundledSnippetsPath] = snippets
callback(snippetsByPath)
})
},
loadUserSnippets (callback) {
const userSnippetsPath = this.getUserSnippetsPath()
fs.stat(userSnippetsPath, (error, stat) => {
if (stat != null && stat.isFile()) {
this.loadSnippetsFile(userSnippetsPath, snippets => {
const result = {}
result[userSnippetsPath] = snippets
callback(result)
})
} else {
callback({})
}
})
},
watchUserSnippets (callback) {
const userSnippetsPath = this.getUserSnippetsPath()
fs.stat(userSnippetsPath, (error, stat) => {
if (stat != null && stat.isFile()) {
const userSnippetsFileDisposable = new CompositeDisposable()
const userSnippetsFile = new File(userSnippetsPath)
try {
userSnippetsFileDisposable.add(userSnippetsFile.onDidChange(() => this.handleUserSnippetsDidChange()))
userSnippetsFileDisposable.add(userSnippetsFile.onDidDelete(() => this.handleUserSnippetsDidChange()))
userSnippetsFileDisposable.add(userSnippetsFile.onDidRename(() => this.handleUserSnippetsDidChange()))
} catch (e) {
const message = `\
Unable to watch path: \`snippets.cson\`. Make sure you have permissions
to the \`~/.pulsar\` directory and \`${userSnippetsPath}\`.
On linux there are currently problems with watch sizes. See
[this document][watches] for more info.
[watches]:https://github.com/atom/atom/blob/master/docs/build-instructions/linux.md#typeerror-unable-to-watch-path\
`
atom.notifications.addError(message, {dismissable: true})
}
callback(userSnippetsFileDisposable)
} else {
callback(new Disposable())
}
})
},
// Called when a user's snippets file is changed, deleted, or moved so that we
// can immediately re-process the snippets it contains.
handleUserSnippetsDidChange () {
// TODO: There appear to be scenarios where this method gets invoked more
// than once with each change to the user's `snippets.cson`. To prevent
// more than one concurrent rescan of the snippets file, we block any
// additional calls to this method while the first call is still operating.
const userSnippetsPath = this.getUserSnippetsPath()
if (this.isHandlingUserSnippetsChange) {
return
}
this.isHandlingUserSnippetsChange = true
atom.config.transact(() => {
this.clearSnippetsForPath(userSnippetsPath)
this.loadSnippetsFile(userSnippetsPath, result => {
this.add(userSnippetsPath, result, 'snippets')
this.isHandlingUserSnippetsChange = false
})
})
},
// Called when the "Enable" checkbox is checked/unchecked in the Snippets
// section of a package's settings view.
handleDisabledPackagesDidChange (newDisabledPackages = [], oldDisabledPackages = []) {
const packagesToAdd = []
const packagesToRemove = []
for (const p of oldDisabledPackages) {
if (!newDisabledPackages.includes(p)) { packagesToAdd.push(p) }
}
for (const p of newDisabledPackages) {
if (!oldDisabledPackages.includes(p)) { packagesToRemove.push(p) }
}
atom.config.transact(() => {
for (const p of packagesToRemove) { this.removeSnippetsForPackage(p) }
for (const p of packagesToAdd) { this.addSnippetsForPackage(p) }
})
},
addSnippetsForPackage (packageName) {
const snippetSet = this.snippetsByPackage.get(packageName)
for (const filePath in snippetSet) {
const snippetsBySelector = snippetSet[filePath]
this.add(filePath, snippetsBySelector, packageName)
}
},
removeSnippetsForPackage (packageName) {
const snippetSet = this.snippetsByPackage.get(packageName)
// Copy these snippets to the "quarantined" ScopedPropertyStore so that they
// remain present in the list of unparsed snippets reported to the settings
// view.
this.addSnippetsInDisabledPackage(snippetSet)
for (const filePath in snippetSet) {
this.clearSnippetsForPath(filePath)
}
},
loadPackageSnippets (callback) {
const disabledPackageNames = atom.config.get('core.packagesWithSnippetsDisabled') || []
const packages = atom.packages.getLoadedPackages().sort((pack, _) => {
return pack.path.includes(`${path.sep}node_modules${path.sep}`) ? -1 : 1
})
const snippetsDirPaths = []
for (const pack of packages) {
snippetsDirPaths.push(path.join(pack.path, 'snippets'))
}
async.map(snippetsDirPaths, this.loadSnippetsDirectory.bind(this), (error, results) => {
const zipped = []
for (const key in results) {
zipped.push({result: results[key], pack: packages[key]})
}
const enabledPackages = []
for (const o of zipped) {
// Skip packages that contain no snippets.
if (Object.keys(o.result).length === 0) { continue }
// Keep track of which snippets come from which packages so we can
// unload them selectively later. All packages get put into this map,
// even disabled packages, because we need to know which snippets to add
// if those packages are enabled again.
this.snippetsByPackage.set(o.pack.name, o.result)
if (disabledPackageNames.includes(o.pack.name)) {
// Since disabled packages' snippets won't get added to the main
// ScopedPropertyStore, we'll keep track of them in a separate
// ScopedPropertyStore so that they can still be represented in the
// settings view.
this.addSnippetsInDisabledPackage(o.result)
} else {
enabledPackages.push(o.result)
}
}
callback(_.extend({}, ...enabledPackages))
})
},
doneLoading () {
this.loaded = true
this.getEmitter().emit('did-load-snippets')
},
onDidLoadSnippets (callback) {
this.getEmitter().on('did-load-snippets', callback)
},
getEmitter () {
if (this.emitter == null) {
this.emitter = new Emitter
}
return this.emitter
},
loadSnippetsDirectory (snippetsDirPath, callback) {
fs.stat(snippetsDirPath, (error, stat) => {
if (error || !stat.isDirectory()) return callback(null, {})
fs.readdir(snippetsDirPath, (error, entries) => {
if (error) {
console.warn(`Error reading snippets directory ${snippetsDirPath}`, error)
return callback(null, {})
}
async.map(
entries,
(entry, done) => {
const filePath = path.join(snippetsDirPath, entry)
this.loadSnippetsFile(filePath, snippets => done(null, {filePath, snippets}))
},
(error, results) => {
const snippetsByPath = {}
for (const {filePath, snippets} of results) {
snippetsByPath[filePath] = snippets
}
callback(null, snippetsByPath)
}
)
})
})
},
loadSnippetsFile (filePath, callback) {
if (!CSON.isObjectPath(filePath)) { return callback({}) }
CSON.readFile(filePath, {allowDuplicateKeys: false}, (error, object = {}) => {
if (error != null) {
console.warn(`Error reading snippets file '${filePath}': ${error.stack != null ? error.stack : error}`)
atom.notifications.addError(`Failed to load snippets from '${filePath}'`, {detail: error.message, dismissable: true})
}
callback(object)
})
},
add (filePath, snippetsBySelector, packageName = null, isDisabled = false) {
packageName ??= 'snippets'
for (const selector in snippetsBySelector) {
const snippetsByName = snippetsBySelector[selector]
const unparsedSnippetsByPrefix = {}
for (const name in snippetsByName) {
const attributes = snippetsByName[name]
const {prefix, command, body} = attributes
if (!prefix && !command) {
// A snippet must define either `prefix` or `command`, or both.
// TODO: Worth showing notification?
console.error(`Skipping snippet ${name}: no "prefix" or "command" property present`)
continue
}
attributes.selector = selector
attributes.name = name
attributes.id = this.snippetIdCounter++
attributes.packageName = packageName
// Snippets with "prefix"es will get indexed according to that prefix.
// Snippets without "prefix"es will be indexed by their ID below _if_
// they have a "command" property. Snippets without "prefix" or
// "command" have already been filtered out.
if (prefix) {
if (typeof body === 'string') {
unparsedSnippetsByPrefix[prefix] = attributes
} else if (body == null) {
unparsedSnippetsByPrefix[prefix] = null
}
}
if (command) {
if (!isValidCommandName(command)) {
showInvalidCommandNameNotification(name, command)
continue
}
if (!prefix) {
// We need a key for these snippets that will not clash with any
// prefix key. Since prefixes aren't allowed to have spaces, we'll
// put a space in this key.
//
// We'll use the snippet ID as part of the key. If a snippet's
// `command` property clashes with another command, we'll catch
// that later.
let unparsedSnippetsKey = `command ${attributes.id}`
if (typeof body === 'string') {
unparsedSnippetsByPrefix[unparsedSnippetsKey] = attributes
} else {
unparsedSnippetsByPrefix[unparsedSnippetsKey] = null
}
}
if (!isDisabled) {
this.addCommandForSnippet(attributes, packageName, selector)
}
}
}
this.storeUnparsedSnippets(unparsedSnippetsByPrefix, filePath, selector, packageName, isDisabled)
}
},
addCommandForSnippet (attributes, packageName, selector) {
packageName = packageName || 'snippets'
let {name, command} = attributes
let commandName = `${packageName}:${command}`
if (CommandMonitor.exists(commandName)) {
console.error(`Skipping ${commandName} because it's already been registered!`)
showCommandNameConflictNotification(
name,
commandName,
packageName,
this.getUserSnippetsPath()
)
// We won't remove the snippet because it might still be triggerable by
// prefix. But we will null out the `command` property to prevent any
// possible confusion.
attributes.command = null
return
}
let commandHandler = (event) => {
let editor = event.target.closest('atom-text-editor').getModel()
// We match the multi-cursor behavior that prefix-triggered snippets
// exhibit: only the last cursor determines which scoped set of snippets
// we pull, but we'll insert this snippet for each cursor, whether it
// happens to be valid for that cursor's scope or not. This could
// possibly be refined in the future.
let snippets = this.getSnippets(editor)
let targetSnippet = null
for (let snippet of Object.values(snippets)) {
if (snippet.id === attributes.id) {
targetSnippet = snippet
break
}
}
if (!targetSnippet) {
// We don't show an error notification here because it isn't
// necessarily a mistake. But we put a warning in the console just in
// case the user is confused.
console.warn(`Snippet “${name}” not invoked because its scope was not matched.`)
// Because its scope was not matched, we abort the key binding; this
// signals to the key binding resolver that it can pick the next
// candidate for a key shortcut, if one exists.
return event.abortKeyBinding()
}
this.expandSnippet(editor, targetSnippet)
}
let disposable = atom.commands.add(
'atom-text-editor',
commandName,
commandHandler
)
this.subscriptions.add(disposable)
CommandMonitor.add(commandName, disposable)
},
addSnippetsInDisabledPackage (bundle) {
for (const filePath in bundle) {
const snippetsBySelector = bundle[filePath]
const packageName = PackageNameResolver.find(filePath)
this.add(filePath, snippetsBySelector, packageName, true)
}
},
getScopeChain (object) {
let scopesArray = object
if (object && object.getScopesArray) {
scopesArray = object.getScopesArray()
}
return scopesArray
.map(scope => scope[0] === '.' ? scope : `.${scope}`)
.join(' ')
},
storeUnparsedSnippets (value, path, selector, packageName, isDisabled = false) {
// The `isDisabled` flag determines which scoped property store we'll use.
// Active snippets get put into one and inactive snippets get put into
// another. Only the first one gets consulted when we look up a snippet
// prefix for expansion, but both stores have their contents exported when
// the settings view asks for all available snippets.
const unparsedSnippets = {}
unparsedSnippets[selector] = {"snippets": value}
const store = isDisabled ? this.disabledSnippetsScopedPropertyStore : this.scopedPropertyStore
store.addProperties(path, unparsedSnippets, {priority: this.priorityForSource(path)})
},
clearSnippetsForPath (path) {
for (const scopeSelector in this.scopedPropertyStore.propertiesForSource(path)) {
let object = this.scopedPropertyStore.propertiesForSourceAndSelector(path, scopeSelector)
if (object.snippets) { object = object.snippets }
for (const prefix in object) {
const attributes = object[prefix]
if (!attributes) { continue }
let {command, packageName} = attributes
if (packageName && command) {
CommandMonitor.remove(`${packageName}:${command}`)
}
this.parsedSnippetsById.delete(attributes.id)
}
this.scopedPropertyStore.removePropertiesForSourceAndSelector(path, scopeSelector)
}
},
parsedSnippetsForScopes (scopeDescriptor) {
let unparsedLegacySnippetsByPrefix
const unparsedSnippetsByPrefix = this.scopedPropertyStore.getPropertyValue(
this.getScopeChain(scopeDescriptor),
"snippets"
)
const legacyScopeDescriptor = atom.config.getLegacyScopeDescriptorForNewScopeDescriptor
? atom.config.getLegacyScopeDescriptorForNewScopeDescriptor(scopeDescriptor)
: undefined
if (legacyScopeDescriptor) {
unparsedLegacySnippetsByPrefix = this.scopedPropertyStore.getPropertyValue(
this.getScopeChain(legacyScopeDescriptor),
"snippets"
)
}
const snippets = {}
if (unparsedSnippetsByPrefix) {
for (const prefix in unparsedSnippetsByPrefix) {
const attributes = unparsedSnippetsByPrefix[prefix]
if (typeof (attributes != null ? attributes.body : undefined) !== 'string') { continue }
snippets[prefix] = this.getParsedSnippet(attributes)
}
}
if (unparsedLegacySnippetsByPrefix) {
for (const prefix in unparsedLegacySnippetsByPrefix) {
const attributes = unparsedLegacySnippetsByPrefix[prefix]
if (snippets[prefix]) { continue }
if (typeof (attributes != null ? attributes.body : undefined) !== 'string') { continue }
snippets[prefix] = this.getParsedSnippet(attributes)
}
}
return snippets
},
getParsedSnippet (attributes) {
let snippet = this.parsedSnippetsById.get(attributes.id)
if (snippet == null) {
let {id, prefix, command, name, body, bodyTree, description, packageName, descriptionMoreURL, rightLabelHTML, leftLabel, leftLabelHTML, selector} = attributes
if (bodyTree == null) { bodyTree = this.getBodyParser().parse(body) }
snippet = new Snippet({id, name, prefix, command, bodyTree, description, packageName, descriptionMoreURL, rightLabelHTML, leftLabel, leftLabelHTML, selector, bodyText: body})
this.parsedSnippetsById.set(attributes.id, snippet)
}
return snippet
},
priorityForSource (source) {
if (source === this.getUserSnippetsPath()) {
return 1000
} else {
return 0
}
},
getBodyParser () {
if (this.bodyParser == null) {
this.bodyParser = require('./snippet-body-parser')
}
return this.bodyParser
},
// Get an {Object} with these keys:
// * `snippetPrefix`: the possible snippet prefix text preceding the cursor
// * `wordPrefix`: the word preceding the cursor
//
// Returns `null` if the values aren't the same for all cursors
getPrefixText (snippets, editor) {
const wordRegex = this.wordRegexForSnippets(snippets)
let snippetPrefix = null
let wordPrefix = null
for (const cursor of editor.getCursors()) {
const position = cursor.getBufferPosition()
const prefixStart = cursor.getBeginningOfCurrentWordBufferPosition({wordRegex})
const cursorSnippetPrefix = editor.getTextInRange([prefixStart, position])
if ((snippetPrefix != null) && (cursorSnippetPrefix !== snippetPrefix)) { return null }
snippetPrefix = cursorSnippetPrefix
const wordStart = cursor.getBeginningOfCurrentWordBufferPosition()
const cursorWordPrefix = editor.getTextInRange([wordStart, position])
if ((wordPrefix != null) && (cursorWordPrefix !== wordPrefix)) { return null }
wordPrefix = cursorWordPrefix
}
return {snippetPrefix, wordPrefix}
},
// Get a RegExp of all the characters used in the snippet prefixes
wordRegexForSnippets (snippets) {
const prefixes = {}
for (const prefix in snippets) {
for (const character of prefix) { prefixes[character] = true }
}
const prefixCharacters = Object.keys(prefixes).join('')
return new RegExp(`[${_.escapeRegExp(prefixCharacters)}]+`)
},
// Get the best match snippet for the given prefix text. This will return
// the longest match where there is no exact match to the prefix text.
snippetForPrefix (snippets, prefix, wordPrefix) {
let longestPrefixMatch = null
for (const snippetPrefix in snippets) {
// Any snippet without a prefix was keyed on its snippet ID, but with a
// space introduced to ensure it would never be a prefix match. But let's
// play it safe here anyway.
if (snippetPrefix.includes(' ')) { continue }
const snippet = snippets[snippetPrefix]
if (prefix.endsWith(snippetPrefix) && (wordPrefix.length <= snippetPrefix.length)) {
if ((longestPrefixMatch == null) || (snippetPrefix.length > longestPrefixMatch.prefix.length)) {
longestPrefixMatch = snippet
}
}
}
return longestPrefixMatch
},
getSnippets (editor) {
return this.parsedSnippetsForScopes(editor.getLastCursor().getScopeDescriptor())
},
snippetToExpandUnderCursor (editor) {
if (!editor.getLastSelection().isEmpty()) { return false }
const snippets = this.getSnippets(editor)
if (_.isEmpty(snippets)) { return false }
const prefixData = this.getPrefixText(snippets, editor)
if (prefixData) {
return this.snippetForPrefix(snippets, prefixData.snippetPrefix, prefixData.wordPrefix)
}
},
// Expands a snippet invoked via command.
expandSnippet (editor, snippet) {
this.getStore(editor).observeHistory({
undo: event => { this.onUndoOrRedo(editor, event, true) },
redo: event => { this.onUndoOrRedo(editor, event, false) }
})
this.findOrCreateMarkerLayer(editor)
editor.transact(() => {
const cursors = editor.getCursors()
for (const cursor of cursors) {
this.insert(snippet, editor, cursor, {method: 'command'})
}
})
},
// Expands a snippet defined via tab trigger _if_ such a snippet can be found
// for the current prefix and scope.
expandSnippetsUnderCursors (editor) {
const snippet = this.snippetToExpandUnderCursor(editor)
if (!snippet) { return false }
this.getStore(editor).observeHistory({
undo: event => { this.onUndoOrRedo(editor, event, true) },
redo: event => { this.onUndoOrRedo(editor, event, false) }
})
this.findOrCreateMarkerLayer(editor)
editor.transact(() => {
const cursors = editor.getCursors()
for (const cursor of cursors) {
// Select the prefix text so that it gets consumed when the snippet
// expands.
const cursorPosition = cursor.getBufferPosition()
const startPoint = cursorPosition.translate([0, -snippet.prefix.length], [0, 0])
cursor.selection.setBufferRange([startPoint, cursorPosition])
this.insert(snippet, editor, cursor, {method: 'prefix'})
}
})
return true
},
goToNextTabStop (editor) {
let nextTabStopVisited = false
for (const expansion of this.getExpansions(editor)) {
if (expansion && expansion.goToNextTabStop()) {
nextTabStopVisited = true
}
}
return nextTabStopVisited
},
goToPreviousTabStop (editor) {
let previousTabStopVisited = false
for (const expansion of this.getExpansions(editor)) {
if (expansion && expansion.goToPreviousTabStop()) {
previousTabStopVisited = true
}
}
return previousTabStopVisited
},
getStore (editor) {
return EditorStore.findOrCreate(editor)
},
findOrCreateMarkerLayer (editor) {
let layer = this.editorMarkerLayers.get(editor)
if (layer === undefined) {
layer = editor.addMarkerLayer({maintainHistory: true})
this.editorMarkerLayers.set(editor, layer)
}
return layer
},
getExpansions (editor) {
return this.getStore(editor).getExpansions()
},
clearExpansions (editor) {
const store = this.getStore(editor)
store.clearExpansions()
// There are no more active instances of this expansion, so we should undo
// the spying we set up on this editor.
store.stopObserving()
store.stopObservingHistory()
},
addExpansion (editor, snippetExpansion) {
this.getStore(editor).addExpansion(snippetExpansion)
},
textChanged (editor, event) {
const store = this.getStore(editor)
const activeExpansions = store.getExpansions()
if ((activeExpansions.length === 0) || activeExpansions[0].isIgnoringBufferChanges) { return }
this.ignoringTextChangesForEditor(editor, () =>
editor.transact(() =>
activeExpansions.map(expansion => expansion.textChanged(event)))
)
// Create a checkpoint here to consolidate all the changes we just made into
// the transaction that prompted them.
this.makeCheckpoint(editor)
},
// Perform an action inside the editor without triggering our `textChanged`
// callback.
ignoringTextChangesForEditor (editor, callback) {
this.stopObservingEditor(editor)
callback()
this.observeEditor(editor)
},
observeEditor (editor) {
this.getStore(editor).observe(event => this.textChanged(editor, event))
},
stopObservingEditor (editor) {
this.getStore(editor).stopObserving()
},
makeCheckpoint (editor) {
this.getStore(editor).makeCheckpoint()
},
insert (snippet, editor, cursor, {method = null} = {}) {
if (editor == null) { editor = atom.workspace.getActiveTextEditor() }
if (cursor == null) { cursor = editor.getLastCursor() }
if (typeof snippet === 'string') {
const bodyTree = this.getBodyParser().parse(snippet)
snippet = new Snippet({id: this.snippetIdCounter++, name: '__anonymous', prefix: '', bodyTree, bodyText: snippet})
}
return new SnippetExpansion(snippet, editor, cursor, this, {method})
},
getUnparsedSnippets () {
const results = []
const iterate = sets => {
for (const item of sets) {
const newItem = _.deepClone(item)
// The atom-slick library has already parsed the `selector` property,
// so it's an AST here instead of a string. The object has a `toString`
// method that turns it back into a string. That custom behavior won't
// be preserved in the deep clone of the object, so we have to handle
// it separately.
newItem.selectorString = item.selector.toString()
results.push(newItem)
}
}
iterate(this.scopedPropertyStore.propertySets)
iterate(this.disabledSnippetsScopedPropertyStore.propertySets)
return results
},
provideSnippets () {
return {
bundledSnippetsLoaded: () => this.loaded,
insertSnippet: this.insert.bind(this),
snippetsForScopes: this.parsedSnippetsForScopes.bind(this),
getUnparsedSnippets: this.getUnparsedSnippets.bind(this),
getUserSnippetsPath: this.getUserSnippetsPath.bind(this)
}
},
onUndoOrRedo (editor, event, isUndo) {
const activeExpansions = this.getExpansions(editor)
activeExpansions.forEach(expansion => expansion.onUndoOrRedo(isUndo))
}
}

View File

@ -0,0 +1,48 @@
const TabStop = require('./tab-stop')
class TabStopList {
constructor (snippet) {
this.snippet = snippet
this.list = {}
}
get length () {
return Object.keys(this.list).length
}
get hasEndStop () {
return !!this.list[Infinity]
}
findOrCreate ({ index, snippet }) {
if (!this.list[index]) {
this.list[index] = new TabStop({ index, snippet })
}
return this.list[index]
}
forEachIndex (iterator) {
let indices = Object.keys(this.list).sort((a1, a2) => a1 - a2)
indices.forEach(iterator)
}
getInsertions () {
let results = []
this.forEachIndex(index => {
results.push(...this.list[index].insertions)
})
return results
}
toArray () {
let results = []
this.forEachIndex(index => {
let tabStop = this.list[index]
if (!tabStop.isValid()) return
results.push(tabStop)
})
return results
}
}
module.exports = TabStopList

View File

@ -0,0 +1,61 @@
const {Range} = require('atom')
const Insertion = require('./insertion')
// A tab stop:
// * belongs to a snippet
// * has an index (one tab stop per index)
// * has multiple Insertions
class TabStop {
constructor ({ snippet, index, insertions }) {
this.insertions = insertions || []
Object.assign(this, { snippet, index })
}
isValid () {
let any = this.insertions.some(insertion => insertion.isTransformation())
if (!any) return true
let all = this.insertions.every(insertion => insertion.isTransformation())
// If there are any transforming insertions, there must be at least one
// non-transforming insertion to act as the primary.
return !all
}
addInsertion ({ range, substitution, references }) {
let insertion = new Insertion({ range, substitution, references })
let insertions = this.insertions
insertions.push(insertion)
insertions = insertions.sort((i1, i2) => {
return i1.range.start.compare(i2.range.start)
})
let initial = insertions.find(insertion => !insertion.isTransformation())
if (initial) {
insertions.splice(insertions.indexOf(initial), 1)
insertions.unshift(initial)
}
this.insertions = insertions
}
copyWithIndent (indent) {
let { snippet, index, insertions } = this
let newInsertions = insertions.map(insertion => {
let { range, substitution } = insertion
let newRange = Range.fromObject(range, true)
if (newRange.start.row) {
newRange.start.column += indent.length
newRange.end.column += indent.length
}
return new Insertion({
range: newRange,
substitution
})
})
return new TabStop({
snippet,
index,
insertions: newInsertions
})
}
}
module.exports = TabStop

View File

@ -0,0 +1,235 @@
const path = require('path')
const crypto = require('crypto')
const Replacer = require('./replacer')
const FLAGS = require('./simple-transformations')
const {remote} = require('electron')
function resolveClipboard () {
return atom.clipboard.read()
}
function makeDateResolver (dateParams) {
// TODO: I do not know if this method ever returns anything other than
// 'en-us'; I suspect it does not. But this is likely the forward-compatible
// way of doing things.
//
// On the other hand, if the output of CURRENT_* variables _did_ vary based
// on locale, we'd probably need to implement a setting to force an arbitrary
// locale. I imagine lots of people use their native language for their OS's
// locale but write code in English.
//
let locale = remote.app.getLocale()
return () => new Date().toLocaleString(locale, dateParams)
}
const RESOLVERS = {
// All the TM_-prefixed variables are part of the LSP specification for
// snippets.
'TM_SELECTED_TEXT' ({editor, selectionRange, method}) {
// When a snippet is inserted via tab trigger, the trigger is
// programmatically selected prior to snippet expansion so that it is
// consumed when the snippet body is inserted. The trigger _should not_ be
// treated as selected text. There is no way for $TM_SELECTED_TEXT to
// contain anything when a snippet is invoked via tab trigger.
if (method === 'prefix') return ''
if (!selectionRange || selectionRange.isEmpty()) return ''
return editor.getTextInBufferRange(selectionRange)
},
'TM_CURRENT_LINE' ({editor, cursor}) {
return editor.lineTextForBufferRow(cursor.getBufferRow())
},
'TM_CURRENT_WORD' ({editor, cursor}) {
return editor.getTextInBufferRange(cursor.getCurrentWordBufferRange())
},
'TM_LINE_INDEX' ({cursor}) {
return `${cursor.getBufferRow()}`
},
'TM_LINE_NUMBER' ({cursor}) {
return `${cursor.getBufferRow() + 1}`
},
'TM_FILENAME' ({editor}) {
return editor.getTitle()
},
'TM_FILENAME_BASE' ({editor}) {
let fileName = editor.getTitle()
if (!fileName) { return undefined }
const index = fileName.lastIndexOf('.')
if (index >= 0) {
return fileName.slice(0, index)
}
return fileName
},
'TM_FILEPATH' ({editor}) {
return editor.getPath()
},
'TM_DIRECTORY' ({editor}) {
const filePath = editor.getPath()
if (filePath === undefined) return undefined
return path.dirname(filePath)
},
// VSCode supports these.
'CLIPBOARD': resolveClipboard,
'CURRENT_YEAR': makeDateResolver({year: 'numeric'}),
'CURRENT_YEAR_SHORT': makeDateResolver({year: '2-digit'}),
'CURRENT_MONTH': makeDateResolver({month: '2-digit'}),
'CURRENT_MONTH_NAME': makeDateResolver({month: 'long'}),
'CURRENT_MONTH_NAME_SHORT': makeDateResolver({month: 'short'}),
'CURRENT_DATE': makeDateResolver({day: '2-digit'}),
'CURRENT_DAY_NAME': makeDateResolver({weekday: 'long'}),
'CURRENT_DAY_NAME_SHORT': makeDateResolver({weekday: 'short'}),
'CURRENT_HOUR': makeDateResolver({hour12: false, hour: '2-digit'}),
'CURRENT_MINUTE': makeDateResolver({minute: '2-digit'}),
'CURRENT_SECOND': makeDateResolver({second: '2-digit'}),
'CURRENT_SECONDS_UNIX': () => {
return Math.floor( Date.now() / 1000 )
},
// NOTE: "Ancestor project path" is determined as follows:
//
// * Get all project paths via `atom.project.getPaths()`.
// * Return the first path (in the order we received) that is an ancestor of
// the current file in the editor.
// The current file's path relative to the ancestor project path.
'RELATIVE_FILEPATH' ({editor}) {
let filePath = editor.getPath()
let projectPaths = atom.project.getPaths()
if (projectPaths.length === 0) { return filePath }
// A project can have multiple path roots. Return whichever is the first
// that is an ancestor of the file path.
let ancestor = projectPaths.find(pp => {
return filePath.startsWith(`${pp}${path.sep}`)
})
if (!ancestor) return {filePath}
return filePath.substring(ancestor.length)
},
// Last path component of the ancestor project path.
'WORKSPACE_NAME' ({editor}) {
let projectPaths = atom.project.getPaths()
if (projectPaths.length === 0) { return '' }
let filePath = editor.getPath()
let ancestor = projectPaths.find(pp => {
return filePath.startsWith(`${pp}${path.sep}`)
})
return path.basename(ancestor)
},
// The full path to the ancestor project path.
'WORKSPACE_FOLDER' ({editor}) {
let projectPaths = atom.project.getPaths()
if (projectPaths.length === 0) { return '' }
let filePath = editor.getPath()
let ancestor = projectPaths.find(pp => {
return filePath.startsWith(`${pp}${path.sep}`)
})
return ancestor
},
'CURSOR_INDEX' ({editor, cursor}) {
let cursors = editor.getCursors()
let index = cursors.indexOf(cursor)
return index >= 0 ? String(index) : ''
},
'CURSOR_NUMBER' ({editor, cursor}) {
let cursors = editor.getCursors()
let index = cursors.indexOf(cursor)
return index >= 0 ? String(index + 1) : ''
},
'RANDOM' () {
return Math.random().toString().slice(-6)
},
'RANDOM_HEX' () {
return Math.random().toString(16).slice(-6)
},
'BLOCK_COMMENT_START' ({editor, cursor}) {
let delimiters = editor.getCommentDelimitersForBufferPosition(
cursor.getBufferPosition()
)
return (delimiters?.block?.[0] ?? '').trim()
},
'BLOCK_COMMENT_END' ({editor, cursor}) {
let delimiters = editor.getCommentDelimitersForBufferPosition(
cursor.getBufferPosition()
)
return (delimiters?.block?.[1] ?? '').trim()
},
'LINE_COMMENT' ({editor, cursor}) {
let delimiters = editor.getCommentDelimitersForBufferPosition(
cursor.getBufferPosition()
)
return (delimiters?.line ?? '').trim()
}
// TODO: VSCode also supports:
//
// UUID
//
// (can be done without dependencies once we use Node >= 14.17.0 or >=
// 15.6.0; see below)
//
}
// $UUID will be easy to implement once Pulsar runs a newer version of Node, so
// there's no reason not to be proactive and sniff for the function we need.
if (('randomUUID' in crypto) && (typeof crypto.randomUUID === 'function')) {
RESOLVERS['UUID'] = () => {
return crypto.randomUUID({disableEntropyCache: true})
}
}
function replaceByFlag (text, flag) {
let replacer = FLAGS[flag]
if (!replacer) { return text }
return replacer(text)
}
class Variable {
constructor ({point, snippet, variable: name, substitution}) {
Object.assign(this, {point, snippet, name, substitution})
}
resolve (params) {
let base = ''
if (this.name in RESOLVERS) {
base = RESOLVERS[this.name](params)
}
if (!this.substitution) {
return base
}
let {flag, find, replace} = this.substitution
// Two kinds of substitution.
if (flag) {
// This is the kind with the trailing `:/upcase`, `:/downcase`, etc.
return replaceByFlag(base, flag)
} else if (find && replace) {
// This is the more complex sed-style substitution.
let {find, replace} = this.substitution
this.replacer ??= new Replacer(replace)
return base.replace(find, (...args) => {
return this.replacer.replace(...args)
})
} else {
return base
}
}
}
module.exports = Variable

View File

@ -0,0 +1,12 @@
'menu': [
'label': 'Packages'
'submenu': [
'label': 'Snippets'
'submenu': [
{ 'label': 'Expand', 'command': 'snippets:show' }
{ 'label': 'Next Stop', 'command': 'snippets:next-tab-stop' }
{ 'label': 'Previous Stop', 'command': 'snippets:previous-tab-stop' }
{ 'label': 'Available', 'command': 'snippets:available' }
]
]
]

2574
packages/snippets/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,31 @@
{
"name": "snippets",
"version": "1.8.0",
"main": "./lib/snippets",
"description": "Expand snippets matching the current prefix with `tab`.",
"repository": "https://github.com/pulsar-edit/pulsar",
"license": "MIT",
"engines": {
"atom": "*"
},
"dependencies": {
"async": "~0.2.6",
"atom-select-list": "^0.7.0",
"pegjs": "^0.10.0",
"scoped-property-store": "^0.17.0",
"season": "^6.0.2",
"temp": "~0.8.0",
"underscore-plus": "^1.0.0"
},
"providedServices": {
"snippets": {
"description": "Snippets are text shortcuts that can be expanded to their definition.",
"versions": {
"0.1.0": "provideSnippets"
}
}
},
"devDependencies": {
"eslint": "^8.35.0"
}
}

View File

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

View File

@ -0,0 +1,704 @@
const BodyParser = require('../lib/snippet-body-parser');
function expectMatch (input, tree) {
expect(BodyParser.parse(input)).toEqual(tree);
}
describe("Snippet Body Parser", () => {
it("parses a snippet with no special behavior", () => {
const bodyTree = BodyParser.parse('${} $ n $}1} ${/upcase/} \n world ${||}');
expect(bodyTree).toEqual([
'${} $ n $}1} ${/upcase/} \n world ${||}'
]);
});
describe('for snippets with variables', () => {
it('parses simple variables', () => {
expectMatch('$f_o_0', [{variable: 'f_o_0'}]);
expectMatch('$_FOO', [{variable: '_FOO'}]);
});
it('parses verbose variables', () => {
expectMatch('${foo}', [{variable: 'foo'}]);
expectMatch('${FOO}', [{variable: 'FOO'}]);
});
it('parses variables with placeholders', () => {
expectMatch(
'${f:placeholder}',
[{variable: 'f', content: ['placeholder']}]
);
expectMatch(
'${f:foo$1 $VAR}',
[
{
variable: 'f',
content: [
'foo',
{index: 1, content: []},
' ',
{variable: 'VAR'}
]
}
]
);
// Allows a colon as part of the placeholder value.
expectMatch(
'${TM_SELECTED_TEXT:foo:bar}',
[
{
variable: 'TM_SELECTED_TEXT',
content: [
'foo:bar'
]
}
]
);
});
it('parses simple transformations like /upcase', () => {
const bodyTree = BodyParser.parse("lorem ipsum ${CLIPBOARD:/upcase} dolor sit amet");
expectMatch(
"lorem ipsum ${CLIPBOARD:/upcase} dolor sit amet",
[
"lorem ipsum ",
{
variable: 'CLIPBOARD',
substitution: {flag: 'upcase'}
},
" dolor sit amet"
]
);
});
it('parses variables with transforms', () => {
expectMatch('${f/.*/$0/}', [
{
variable: 'f',
substitution: {
find: /.*/,
replace: [
{backreference: 0}
]
}
}
]);
});
});
describe('for snippets with tabstops', () => {
it('parses simple tabstops', () => {
expectMatch('hello$1world$2', [
'hello',
{index: 1, content: []},
'world',
{index: 2, content: []}
]);
});
it('parses verbose tabstops', () => {
expectMatch('hello${1}world${2}', [
'hello',
{index: 1, content: []},
'world',
{index: 2, content: []}
]);
});
it('skips escaped tabstops', () => {
expectMatch('$1 \\$2 $3 \\\\$4 \\\\\\$5 $6', [
{index: 1, content: []},
' $2 ',
{index: 3, content: []},
' \\',
{index: 4, content: []},
' \\$5 ',
{index: 6, content: []}
]);
});
describe('for tabstops with placeholders', () => {
it('parses them', () => {
expectMatch('hello${1:placeholder}world', [
'hello',
{index: 1, content: ['placeholder']},
'world'
]);
});
it('allows escaped back braces', () => {
expectMatch('${1:{}}', [
{index: 1, content: ['{']},
'}'
]);
expectMatch('${1:{\\}}', [
{index: 1, content: ['{}']}
]);
});
});
it('parses tabstops with transforms', () => {
expectMatch('${1/.*/$0/}', [
{
index: 1,
content: [],
substitution: {
find: /.*/,
replace: [{backreference: 0}]
}
}
]);
});
it('parses tabstops with choices', () => {
expectMatch('${1|on}e,t\\|wo,th\\,ree|}', [
{index: 1, content: ['on}e'], choice: ['on}e', 't|wo', 'th,ree']}
]);
});
it('parses if-else syntax', () => {
expectMatch(
'$1 ${1/(?:(wat)|^.*$)$/${1:+hey}/}',
[
{index: 1, content: []},
" ",
{
index: 1,
content: [],
substitution: {
find: /(?:(wat)|^.*$)$/,
replace: [
{
backreference: 1,
iftext: "hey",
elsetext: ""
}
],
},
},
]
);
expectMatch(
'$1 ${1/(?:(wat)|^.*$)$/${1:?hey:nah}/}',
[
{index: 1, content: []},
" ",
{
index: 1,
content: [],
substitution: {
find: /(?:(wat)|^.*$)$/,
replace: [
{
backreference: 1,
iftext: "hey",
elsetext: "nah"
}
],
},
},
]
);
// else with `:` syntax
expectMatch(
'$1 ${1/(?:(wat)|^.*$)$/${1:fallback}/}',
[
{index: 1, content: []},
" ",
{
index: 1,
content: [],
substitution: {
find: /(?:(wat)|^.*$)$/,
replace: [
{
backreference: 1,
iftext: "",
elsetext: "fallback"
}
],
},
},
]
);
// else with `:-` syntax; should be same as above
expectMatch(
'$1 ${1/(?:(wat)|^.*$)$/${1:-fallback}/}',
[
{index: 1, content: []},
" ",
{
index: 1,
content: [],
substitution: {
find: /(?:(wat)|^.*$)$/,
replace: [
{
backreference: 1,
iftext: "",
elsetext: "fallback"
}
],
},
},
]
);
});
it('parses alternative if-else syntax', () => {
expectMatch(
'$1 ${1/(?:(wat)|^.*$)$/(?1:hey:)/}',
[
{index: 1, content: []},
" ",
{
index: 1,
content: [],
substitution: {
find: /(?:(wat)|^.*$)$/,
replace: [
{
backreference: 1,
iftext: ["hey"],
elsetext: ""
}
],
},
},
]
);
expectMatch(
'$1 ${1/(?:(wat)|^.*$)$/(?1:\\u$1:)/}',
[
{index: 1, content: []},
" ",
{
index: 1,
content: [],
substitution: {
find: /(?:(wat)|^.*$)$/,
replace: [
{
backreference: 1,
iftext: [
{escape: 'u'},
{backreference: 1}
],
elsetext: ""
}
],
},
},
]
);
expectMatch(
'$1 ${1/(?:(wat)|^.*$)$/(?1::hey)/}',
[
{index: 1, content: []},
" ",
{
index: 1,
content: [],
substitution: {
find: /(?:(wat)|^.*$)$/,
replace: [
{
backreference: 1,
iftext: "",
elsetext: ["hey"]
}
],
},
},
]
);
expectMatch(
'class ${1:${TM_FILENAME/(?:\\A|_)([A-Za-z0-9]+)(?:\\.rb)?/(?2::\\u$1)/g}} < ${2:Application}Controller\n $3\nend',
[
'class ',
{
index: 1,
content: [
{
variable: 'TM_FILENAME',
substitution: {
find: /(?:\A|_)([A-Za-z0-9]+)(?:\.rb)?/g,
replace: [
{
backreference: 2,
iftext: '',
elsetext: [
{escape: 'u'},
{backreference: 1}
]
}
]
}
}
]
},
' < ',
{
index: 2,
content: ['Application']
},
'Controller\n ',
{index: 3, content : []},
'\nend'
]
);
});
it('recognizes escape characters in if/else syntax', () => {
expectMatch(
'$1 ${1/(?:(wat)|^.*$)$/${1:?hey\\:hey:nah}/}',
[
{index: 1, content: []},
" ",
{
index: 1,
content: [],
substitution: {
find: /(?:(wat)|^.*$)$/,
replace: [
{
backreference: 1,
iftext: "hey:hey",
elsetext: "nah"
}
],
},
},
]
);
expectMatch(
'$1 ${1/(?:(wat)|^.*$)$/${1:?hey:n\\}ah}/}',
[
{index: 1, content: []},
" ",
{
index: 1,
content: [],
substitution: {
find: /(?:(wat)|^.*$)$/,
replace: [
{
backreference: 1,
iftext: "hey",
elsetext: "n}ah"
}
],
},
},
]
);
});
it('parses nested tabstops', () => {
expectMatch(
'${1:place${2:hol${3:der}}}',
[
{
index: 1,
content: [
'place',
{index: 2, content: [
'hol',
{index: 3, content: ['der']}
]}
]
}
]
);
expectMatch(
'${1:${foo:${1}}}',
[
{
index: 1,
content: [
{
variable: 'foo',
content: [
{
index: 1,
content: []
}
]
}
]
}
]
);
});
});
it("breaks a snippet body into lines, with each line containing tab stops at the appropriate position", () => {
const bodyTree = BodyParser.parse(`\
the quick brown $1fox \${2:jumped \${3:over}
}the \${4:lazy} dog\
`
);
expect(bodyTree).toEqual([
"the quick brown ",
{index: 1, content: []},
"fox ",
{
index: 2,
content: [
"jumped ",
{index: 3, content: ["over"]},
"\n"
],
},
"the ",
{index: 4, content: ["lazy"]},
" dog"
]);
});
it('handles a snippet with a transformed variable', () => {
expectMatch(
'module ${1:ActiveRecord::${TM_FILENAME/(?:\\A|_)([A-Za-z0-9]+)(?:\\.rb)?/\\u$1/g}}',
[
'module ',
{
index: 1,
content: [
'ActiveRecord::',
{
variable: 'TM_FILENAME',
substitution: {
find: /(?:\A|_)([A-Za-z0-9]+)(?:\.rb)?/g,
replace: [
{escape: 'u'},
{backreference: 1}
]
}
}
]
}
]
);
});
it("skips escaped tabstops", () => {
const bodyTree = BodyParser.parse("snippet $1 escaped \\$2 \\\\$3");
expect(bodyTree).toEqual([
"snippet ",
{
index: 1,
content: []
},
" escaped $2 \\",
{
index: 3,
content: []
}
]);
});
it("includes escaped right-braces", () => {
const bodyTree = BodyParser.parse("snippet ${1:{\\}}");
expect(bodyTree).toEqual([
"snippet ",
{
index: 1,
content: ["{}"]
}
]);
});
it("parses a snippet with transformations", () => {
const bodyTree = BodyParser.parse("<${1:p}>$0</${1/f/F/}>");
expect(bodyTree).toEqual([
'<',
{index: 1, content: ['p']},
'>',
{index: 0, content: []},
'</',
{index: 1, content: [], substitution: {find: /f/, replace: ['F']}},
'>'
]);
});
it("parses a snippet with transformations and a global flag", () => {
const bodyTree = BodyParser.parse("<${1:p}>$0</${1/f/F/g}>");
expect(bodyTree).toEqual([
'<',
{index: 1, content: ['p']},
'>',
{index: 0, content: []},
'</',
{index: 1, content: [], substitution: {find: /f/g, replace: ['F']}},
'>'
]);
});
it("parses a snippet with multiple tab stops with transformations", () => {
const bodyTree = BodyParser.parse("${1:placeholder} ${1/(.)/\\u$1/g} $1 ${2:ANOTHER} ${2/^(.*)$/\\L$1/} $2");
expect(bodyTree).toEqual([
{index: 1, content: ['placeholder']},
' ',
{
index: 1,
content: [],
substitution: {
find: /(.)/g,
replace: [
{escape: 'u'},
{backreference: 1}
]
}
},
' ',
{index: 1, content: []},
' ',
{index: 2, content: ['ANOTHER']},
' ',
{
index: 2,
content: [],
substitution: {
find: /^(.*)$/,
replace: [
{escape: 'L'},
{backreference: 1}
]
}
},
' ',
{index: 2, content: []},
]);
});
it("parses a snippet with transformations and mirrors", () => {
const bodyTree = BodyParser.parse("${1:placeholder}\n${1/(.)/\\u$1/g}\n$1");
expect(bodyTree).toEqual([
{index: 1, content: ['placeholder']},
'\n',
{
index: 1,
content: [],
substitution: {
find: /(.)/g,
replace: [
{escape: 'u'},
{backreference: 1}
]
}
},
'\n',
{index: 1, content: []}
]);
});
it("parses a snippet with a format string and case-control flags", () => {
const bodyTree = BodyParser.parse("<${1:p}>$0</${1/(.)(.*)/\\u$1$2/g}>");
expect(bodyTree).toEqual([
'<',
{index: 1, content: ['p']},
'>',
{index: 0, content: []},
'</',
{
index: 1,
content: [],
substitution: {
find: /(.)(.*)/g,
replace: [
{escape: 'u'},
{backreference: 1},
{backreference: 2}
]
}
},
'>'
]);
});
it("parses a snippet with an escaped forward slash in a transform", () => {
// Annoyingly, a forward slash needs to be double-backslashed just like the
// other escapes.
const bodyTree = BodyParser.parse("<${1:p}>$0</${1/(.)\\/(.*)/\\u$1$2/g}>");
expect(bodyTree).toEqual([
'<',
{index: 1, content: ['p']},
'>',
{index: 0, content: []},
'</',
{
index: 1,
content: [],
substitution: {
find: /(.)\/(.*)/g,
replace: [
{escape: 'u'},
{backreference: 1},
{backreference: 2}
]
}
},
'>'
]);
});
it("parses a snippet with a placeholder that mirrors another tab stop's content", () => {
const bodyTree = BodyParser.parse("$4console.${3:log}('${2:$1}', $1);$0");
expect(bodyTree).toEqual([
{index: 4, content: []},
'console.',
{index: 3, content: ['log']},
'(\'',
{
index: 2, content: [
{index: 1, content: []}
]
},
'\', ',
{index: 1, content: []},
');',
{index: 0, content: []}
]);
});
it("parses a snippet with a placeholder that mixes text and tab stop references", () => {
const bodyTree = BodyParser.parse("$4console.${3:log}('${2:uh $1}', $1);$0");
expect(bodyTree).toEqual([
{index: 4, content: []},
'console.',
{index: 3, content: ['log']},
'(\'',
{
index: 2, content: [
'uh ',
{index: 1, content: []}
]
},
'\', ',
{index: 1, content: []},
');',
{index: 0, content: []}
]);
});
});

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,31 @@
".test":
"Test Snippet":
prefix: "test"
body: "testing 123"
"Test Snippet With Description":
prefix: "testd"
body: "testing 456"
description: "a description"
descriptionMoreURL: "http://google.com"
"Test Snippet With A Label On The Left":
prefix: "testlabelleft"
body: "testing 456"
leftLabel: "a label"
"Test Snippet With HTML Labels":
prefix: "testhtmllabels"
body: "testing 456"
leftLabelHTML: "<span style=\"color:red\">Label</span>"
rightLabelHTML: "<span style=\"color:white\">Label</span>"
".package-with-snippets-unique-scope":
"Test Snippet":
prefix: "test"
body: "testing 123"
".source.js":
"Overrides a core package's snippet":
prefix: "log"
body: "from-a-community-package"
"Maps to a command":
body: 'lorem ipsum $0 dolor sit amet'
command: 'test-command-name'

View File

@ -0,0 +1,13 @@
var quicksort = function () {
var sort = function(items) {
if (items.length <= 1) return items;
var pivot = items.shift(), current, left = [], right = [];
while(items.length > 0) {
current = items.shift();
current < pivot ? left.push(current) : right.push(current);
}
return sort(left).concat(pivot).concat(sort(right));
};
return sort(Array.apply(this, arguments));
};

View File

@ -0,0 +1,134 @@
const Insertion = require('../lib/insertion')
const { Range } = require('atom')
const range = new Range(0, 0)
describe('Insertion', () => {
it('returns what it was given when it has no substitution', () => {
let insertion = new Insertion({
range,
substitution: undefined
})
let transformed = insertion.transform('foo!')
expect(transformed).toEqual('foo!')
})
it('transforms what it was given when it has a regex transformation', () => {
let insertion = new Insertion({
range,
substitution: {
find: /foo/g,
replace: ['bar']
}
})
let transformed = insertion.transform('foo!')
expect(transformed).toEqual('bar!')
})
it('transforms the case of the next character when encountering a \\u or \\l flag', () => {
let uInsertion = new Insertion({
range,
substitution: {
find: /(.)(.)(.*)/g,
replace: [
{ backreference: 1 },
{ escape: 'u' },
{ backreference: 2 },
{ backreference: 3 }
]
}
})
expect(uInsertion.transform('foo!')).toEqual('fOo!')
expect(uInsertion.transform('fOo!')).toEqual('fOo!')
expect(uInsertion.transform('FOO!')).toEqual('FOO!')
let lInsertion = new Insertion({
range,
substitution: {
find: /(.{2})(.)(.*)/g,
replace: [
{ backreference: 1 },
{ escape: 'l' },
{ backreference: 2 },
{ backreference: 3 }
]
}
})
expect(lInsertion.transform('FOO!')).toEqual('FOo!')
expect(lInsertion.transform('FOo!')).toEqual('FOo!')
expect(lInsertion.transform('FoO!')).toEqual('Foo!')
expect(lInsertion.transform('foo!')).toEqual('foo!')
})
it('transforms the case of all remaining characters when encountering a \\U or \\L flag, up until it sees a \\E flag', () => {
let uInsertion = new Insertion({
range,
substitution: {
find: /(.)(.*)/,
replace: [
{ backreference: 1 },
{ escape: 'U' },
{ backreference: 2 }
]
}
})
expect(uInsertion.transform('lorem ipsum!')).toEqual('lOREM IPSUM!')
expect(uInsertion.transform('lOREM IPSUM!')).toEqual('lOREM IPSUM!')
expect(uInsertion.transform('LOREM IPSUM!')).toEqual('LOREM IPSUM!')
let ueInsertion = new Insertion({
range,
substitution: {
find: /(.)(.{3})(.*)/,
replace: [
{ backreference: 1 },
{ escape: 'U' },
{ backreference: 2 },
{ escape: 'E' },
{ backreference: 3 }
]
}
})
expect(ueInsertion.transform('lorem ipsum!')).toEqual('lOREm ipsum!')
expect(ueInsertion.transform('lOREm ipsum!')).toEqual('lOREm ipsum!')
expect(ueInsertion.transform('LOREM IPSUM!')).toEqual('LOREM IPSUM!')
let lInsertion = new Insertion({
range,
substitution: {
find: /(.{4})(.)(.*)/,
replace: [
{ backreference: 1 },
{ escape: 'L' },
{ backreference: 2 },
'WHAT'
]
}
})
expect(lInsertion.transform('LOREM IPSUM!')).toEqual('LOREmwhat')
let leInsertion = new Insertion({
range,
substitution: {
find: /^([A-Fa-f])(.*)(.)$/,
replace: [
{ backreference: 1 },
{ escape: 'L' },
{ backreference: 2 },
{ escape: 'E' },
{ backreference: 3 }
]
}
})
expect(leInsertion.transform('LOREM IPSUM!')).toEqual('LOREM IPSUM!')
expect(leInsertion.transform('CONSECUETUR')).toEqual('ConsecuetuR')
})
})

View File

@ -0,0 +1,345 @@
const path = require('path');
const fs = require('fs');
const temp = require('temp').track();
describe("Snippet Loading", () => {
let configDirPath, snippetsService;
beforeEach(() => {
configDirPath = temp.mkdirSync('atom-config-dir-');
spyOn(atom, 'getConfigDirPath').andReturn(configDirPath);
spyOn(console, 'warn');
if (atom.notifications != null) { spyOn(atom.notifications, 'addError'); }
spyOn(atom.packages, 'getLoadedPackages').andReturn([
atom.packages.loadPackage(path.join(__dirname, 'fixtures', 'package-with-snippets')),
atom.packages.loadPackage(path.join(__dirname, 'fixtures', 'package-with-broken-snippets')),
]);
});
afterEach(() => {
waitsForPromise(() => Promise.resolve(atom.packages.deactivatePackages('snippets')));
runs(() => {
jasmine.unspy(atom.packages, 'getLoadedPackages');
});
});
const activateSnippetsPackage = () => {
waitsForPromise(() => atom.packages.activatePackage("snippets").then(({mainModule}) => {
snippetsService = mainModule.provideSnippets();
mainModule.loaded = false;
}));
waitsFor("all snippets to load", 3000, () => snippetsService.bundledSnippetsLoaded());
};
it("loads the bundled snippet template snippets", () => {
activateSnippetsPackage();
runs(() => {
const jsonSnippet = snippetsService.snippetsForScopes(['.source.json'])['snip'];
expect(jsonSnippet.name).toBe('Atom Snippet');
expect(jsonSnippet.prefix).toBe('snip');
expect(jsonSnippet.body).toContain('"prefix":');
expect(jsonSnippet.body).toContain('"body":');
expect(jsonSnippet.tabStopList.length).toBeGreaterThan(0);
const csonSnippet = snippetsService.snippetsForScopes(['.source.coffee'])['snip'];
expect(csonSnippet.name).toBe('Atom Snippet');
expect(csonSnippet.prefix).toBe('snip');
expect(csonSnippet.body).toContain("'prefix':");
expect(csonSnippet.body).toContain("'body':");
expect(csonSnippet.tabStopList.length).toBeGreaterThan(0);
});
});
it("loads non-hidden snippet files from atom packages with snippets directories", () => {
activateSnippetsPackage();
runs(() => {
let snippet = snippetsService.snippetsForScopes(['.test'])['test'];
expect(snippet.prefix).toBe('test');
expect(snippet.body).toBe('testing 123');
snippet = snippetsService.snippetsForScopes(['.test'])['testd'];
expect(snippet.prefix).toBe('testd');
expect(snippet.body).toBe('testing 456');
expect(snippet.description).toBe('a description');
expect(snippet.descriptionMoreURL).toBe('http://google.com');
snippet = snippetsService.snippetsForScopes(['.test'])['testlabelleft'];
expect(snippet.prefix).toBe('testlabelleft');
expect(snippet.body).toBe('testing 456');
expect(snippet.leftLabel).toBe('a label');
snippet = snippetsService.snippetsForScopes(['.test'])['testhtmllabels'];
expect(snippet.prefix).toBe('testhtmllabels');
expect(snippet.body).toBe('testing 456');
expect(snippet.leftLabelHTML).toBe('<span style=\"color:red\">Label</span>');
expect(snippet.rightLabelHTML).toBe('<span style=\"color:white\">Label</span>');
});
});
it("registers a command if a package snippet defines one", () => {
waitsForPromise(() => {
return atom.packages.activatePackage("snippets").then(
({mainModule}) => {
return new Promise((resolve) => {
mainModule.onDidLoadSnippets(resolve);
});
}
);
});
runs(() => {
expect(
'package-with-snippets:test-command-name' in atom.commands.registeredCommands
).toBe(true);
});
});
it("logs a warning if package snippets files cannot be parsed", () => {
activateSnippetsPackage();
runs(() => {
// Warn about invalid-file, but don't even try to parse a hidden file
expect(console.warn.calls.length).toBeGreaterThan(0);
expect(console.warn.mostRecentCall.args[0]).toMatch(/Error reading.*package-with-broken-snippets/);
});
});
describe("::loadPackageSnippets(callback)", () => {
const jsPackage = () => {
const pack = atom.packages.loadPackage('language-javascript')
pack.path = path.join(
atom.getLoadSettings().resourcePath,
'node_modules', 'language-javascript'
)
return pack
}
beforeEach(() => { // simulate a list of packages where the javascript core package is returned at the end
atom.packages.getLoadedPackages.andReturn([
atom.packages.loadPackage(path.join(__dirname, 'fixtures', 'package-with-snippets')),
jsPackage()
]);
});
// NOTE: This spec will fail if you're hacking on the Pulsar source code
// with `ATOM_DEV_RESOURCE_PATH`. Just make sure it passes in CI and you'll
// be fine.
it("allows other packages to override core packages' snippets", () => {
waitsForPromise(() => atom.packages.activatePackage("language-javascript"));
activateSnippetsPackage();
runs(() => {
const snippet = snippetsService.snippetsForScopes(['.source.js'])['log'];
expect(snippet.body).toBe("from-a-community-package");
});
});
});
describe("::onDidLoadSnippets(callback)", () => {
it("invokes listeners when all snippets are loaded", () => {
let loadedCallback = null;
waitsFor("package to activate", done => atom.packages.activatePackage("snippets").then(({mainModule}) => {
mainModule.onDidLoadSnippets(loadedCallback = jasmine.createSpy('onDidLoadSnippets callback'));
done();
}));
waitsFor("onDidLoad callback to be called", () => loadedCallback.callCount > 0);
});
});
describe("when ~/.atom/snippets.json exists", () => {
beforeEach(() => {
fs.mkdirSync(configDirPath, {recursive: true});
fs.writeFileSync(path.join(configDirPath, 'snippets.json'), `\
{
".foo": {
"foo snippet": {
"prefix": "foo",
"body": "bar1"
}
}
}\
`
);
activateSnippetsPackage();
});
it("loads the snippets from that file", () => {
let snippet = null;
waitsFor(() => snippet = snippetsService.snippetsForScopes(['.foo'])['foo']);
runs(() => {
expect(snippet.name).toBe('foo snippet');
expect(snippet.prefix).toBe("foo");
expect(snippet.body).toBe("bar1");
});
});
describe("when that file changes", () => {
it("reloads the snippets", () => {
fs.mkdirSync(configDirPath, {recursive: true});
fs.writeFileSync(path.join(configDirPath, 'snippets.json'), `\
{
".foo": {
"foo snippet": {
"prefix": "foo",
"body": "bar2"
}
}
}\
`
);
waitsFor("snippets to be changed", () => {
const snippet = snippetsService.snippetsForScopes(['.foo'])['foo'];
return snippet && snippet.body === 'bar2';
});
runs(() => {
fs.mkdirSync(configDirPath, {recursive: true});
fs.writeFileSync(path.join(configDirPath, 'snippets.json'), "");
});
waitsFor("snippets to be removed", () => !snippetsService.snippetsForScopes(['.foo'])['foo']);
});
});
});
describe("when ~/.atom/snippets.cson exists", () => {
beforeEach(() => {
fs.mkdirSync(configDirPath, {recursive: true});
fs.writeFileSync(path.join(configDirPath, 'snippets.cson'), `\
".foo":
"foo snippet":
"prefix": "foo"
"body": "bar1"\
`
);
activateSnippetsPackage();
});
it("loads the snippets from that file", () => {
let snippet = null;
waitsFor(() => snippet = snippetsService.snippetsForScopes(['.foo'])['foo']);
runs(() => {
expect(snippet.name).toBe('foo snippet');
expect(snippet.prefix).toBe("foo");
expect(snippet.body).toBe("bar1");
});
});
describe("when that file changes", () => {
it("reloads the snippets", () => {
fs.mkdirSync(configDirPath, {recursive: true});
fs.writeFileSync(path.join(configDirPath, 'snippets.cson'), `\
".foo":
"foo snippet":
"prefix": "foo"
"body": "bar2"\
`
);
waitsFor("snippets to be changed", () => {
const snippet = snippetsService.snippetsForScopes(['.foo'])['foo'];
return snippet && snippet.body === 'bar2';
});
runs(() => {
fs.mkdirSync(configDirPath, {recursive: true});
fs.writeFileSync(path.join(configDirPath, 'snippets.cson'), "");
});
waitsFor("snippets to be removed", () => {
const snippet = snippetsService.snippetsForScopes(['.foo'])['foo'];
return snippet == null;
});
});
});
});
it("notifies the user when the user snippets file cannot be loaded", () => {
fs.writeFileSync(path.join(configDirPath, 'snippets.cson'), '".junk":::');
activateSnippetsPackage();
runs(() => {
expect(console.warn).toHaveBeenCalled();
if (atom.notifications != null) {
expect(atom.notifications.addError).toHaveBeenCalled();
}
});
});
describe("packages-with-snippets-disabled feature", () => {
it("disables no snippets if the config option is empty", () => {
const originalConfig = atom.config.get('core.packagesWithSnippetsDisabled');
atom.config.set('core.packagesWithSnippetsDisabled', []);
activateSnippetsPackage();
runs(() => {
const snippets = snippetsService.snippetsForScopes(['.package-with-snippets-unique-scope']);
expect(Object.keys(snippets).length).toBe(1);
atom.config.set('core.packagesWithSnippetsDisabled', originalConfig);
});
});
it("still includes a disabled package's snippets in the list of unparsed snippets", () => {
let originalConfig = atom.config.get('core.packagesWithSnippetsDisabled');
atom.config.set('core.packagesWithSnippetsDisabled', []);
activateSnippetsPackage();
runs(() => {
atom.config.set('core.packagesWithSnippetsDisabled', ['package-with-snippets']);
const allSnippets = snippetsService.getUnparsedSnippets();
const scopedSnippet = allSnippets.find(s => s.selectorString === '.package-with-snippets-unique-scope');
expect(scopedSnippet).not.toBe(undefined);
atom.config.set('core.packagesWithSnippetsDisabled', originalConfig);
});
});
it("never loads a package's snippets when that package is disabled in config", () => {
const originalConfig = atom.config.get('core.packagesWithSnippetsDisabled');
atom.config.set('core.packagesWithSnippetsDisabled', ['package-with-snippets']);
activateSnippetsPackage();
runs(() => {
const snippets = snippetsService.snippetsForScopes(['.package-with-snippets-unique-scope']);
expect(Object.keys(snippets).length).toBe(0);
atom.config.set('core.packagesWithSnippetsDisabled', originalConfig);
});
});
it("unloads and/or reloads snippets from a package if the config option is changed after activation", () => {
const originalConfig = atom.config.get('core.packagesWithSnippetsDisabled');
atom.config.set('core.packagesWithSnippetsDisabled', []);
activateSnippetsPackage();
runs(() => {
let snippets = snippetsService.snippetsForScopes(['.package-with-snippets-unique-scope']);
expect(Object.keys(snippets).length).toBe(1);
// Disable it.
atom.config.set('core.packagesWithSnippetsDisabled', ['package-with-snippets']);
snippets = snippetsService.snippetsForScopes(['.package-with-snippets-unique-scope']);
expect(Object.keys(snippets).length).toBe(0);
// Re-enable it.
atom.config.set('core.packagesWithSnippetsDisabled', []);
snippets = snippetsService.snippetsForScopes(['.package-with-snippets-unique-scope']);
expect(Object.keys(snippets).length).toBe(1);
atom.config.set('core.packagesWithSnippetsDisabled', originalConfig);
});
});
});
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,67 @@
const Variable = require('../lib/variable');
const {Point} = require('atom');
describe('Variable', () => {
let fakeCursor = {
getCurrentWordBufferRange () { return true; },
getBufferRow () { return 9; },
};
let fakeSelectionRange = {
isEmpty: () => false
};
let fakeEditor = {
getTitle () { return 'foo.rb'; },
getPath () { return '/Users/pulsar/code/foo.rb'; },
getTextInBufferRange (x) {
return x === true ? 'word' : 'this text is selected';
},
lineTextForBufferRow () {
return `this may be considered an entire line for the purposes of variable tests`;
}
};
let fakeParams = {editor: fakeEditor, cursor: fakeCursor, selectionRange: fakeSelectionRange};
it('resolves to the right value', () => {
const expected = {
'TM_FILENAME': 'foo.rb',
'TM_FILENAME_BASE': 'foo',
'TM_CURRENT_LINE': `this may be considered an entire line for the purposes of variable tests`,
'TM_CURRENT_WORD': 'word',
'TM_LINE_INDEX': '9',
'TM_LINE_NUMBER': '10',
'TM_DIRECTORY': '/Users/pulsar/code',
'TM_SELECTED_TEXT': 'this text is selected'
};
for (let variable in expected) {
let vrbl = new Variable({variable});
expect(
vrbl.resolve(fakeParams)
).toEqual(expected[variable]);
}
});
it('transforms', () => {
let vrbl = new Variable({
variable: 'TM_FILENAME',
substitution: {
find: /(?:^|_)([A-Za-z0-9]+)(?:\.rb)?/g,
replace: [
{escape: 'u'},
{backreference: 1}
]
},
point: new Point(0, 0),
snippet: {}
});
expect(
vrbl.resolve({editor: fakeEditor})
).toEqual('Foo');
});
});

View File

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

View File

@ -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">&lt;p&gt;sit amet&lt;/p&gt;
</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)"))

View File

@ -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}`)
}
}
}

View File

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

View File

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

View File

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