diff --git a/src/vs/editor/contrib/snippet/snippet.md b/src/vs/editor/contrib/snippet/snippet.md index 403cda7743839..6b29d44c16e0c 100644 --- a/src/vs/editor/contrib/snippet/snippet.md +++ b/src/vs/editor/contrib/snippet/snippet.md @@ -53,6 +53,26 @@ ${TM_FILENAME/(.*)\..+$/$1/} |-> resolves to the filename ``` +Placeholder-Transform +-- + +Like a Variable-Transform, a transformation of a placeholder allows changing the inserted text for the placeholder when moving to the next tab stop. +The inserted text is matched with the regular expression and the match or matches - depending on the options - are replaced with the specified replacement format text. +Every occurrence of a placeholder can define its own transformation independently using the value of the first placeholder. +The format for Placeholder-Transforms is the same as for Variable-Transforms. + +The following sample removes an underscore at the beginning of the text. `_transform` becomes `transform`. + +``` +${1/^_(.*)/$1/} + | | | |-> No options + | | | + | | |-> Replace it with the first capture group + | | + | |-> Regular expression to capture everything after the underscore + | + |-> Placeholder Index +``` Grammar -- @@ -61,12 +81,17 @@ Below is the EBNF for snippets. With `\` (backslash) you can escape `$`, `}` and ``` any ::= tabstop | placeholder | choice | variable | text -tabstop ::= '$' int | '${' int '}' +tabstop ::= '$' int + | '${' int '}' + | '${' int transform '}' placeholder ::= '${' int ':' any '}' + | '${' int ':' any transform '}' choice ::= '${' int '|' text (',' text)* '|}' + | '${' int '|' text (',' text)* '|' transform '}' variable ::= '$' var | '${' var }' | '${' var ':' any '}' - | '${' var '/' regex '/' (format | text)+ '/' options '}' + | '${' var transform '}' +transform ::= '/' regex '/' (format | text)+ '/' options format ::= '$' int | '${' int '}' | '${' int ':' '/upcase' | '/downcase' | '/capitalize' '}' | '${' int ':+' if '}' @@ -78,3 +103,5 @@ var ::= [_a-zA-Z] [_a-zA-Z0-9]* int ::= [0-9]+ text ::= .* ``` + +Transformations for placeholders and choices are an extension to the TextMate snippet grammar and only support by Visual Studio Code. \ No newline at end of file diff --git a/src/vs/editor/contrib/snippet/snippetParser.ts b/src/vs/editor/contrib/snippet/snippetParser.ts index 2ee46939849ad..7ab0d57eafeeb 100644 --- a/src/vs/editor/contrib/snippet/snippetParser.ts +++ b/src/vs/editor/contrib/snippet/snippetParser.ts @@ -214,8 +214,11 @@ export class Text extends Marker { } } -export class Placeholder extends Marker { +export abstract class TransformableMarker extends Marker { + public transform: Transform; +} +export class Placeholder extends TransformableMarker { static compareByIndex(a: Placeholder, b: Placeholder): number { if (a.index === b.index) { return 0; @@ -247,17 +250,26 @@ export class Placeholder extends Marker { } toTextmateString(): string { - if (this.children.length === 0) { + let transformString = ''; + if (this.transform) { + transformString = this.transform.toTextmateString(); + } + if (this.children.length === 0 && !this.transform) { return `\$${this.index}`; + } else if (this.children.length === 0) { + return `\${${this.index}${transformString}}`; } else if (this.choice) { - return `\${${this.index}|${this.choice.toTextmateString()}|}`; + return `\${${this.index}|${this.choice.toTextmateString()}|${transformString}}`; } else { - return `\${${this.index}:${this.children.map(child => child.toTextmateString()).join('')}}`; + return `\${${this.index}:${this.children.map(child => child.toTextmateString()).join('')}${transformString}}`; } } clone(): Placeholder { let ret = new Placeholder(this.index); + if (this.transform) { + ret.transform = this.transform.clone(); + } ret._children = this.children.map(child => child.clone()); return ret; } @@ -384,7 +396,7 @@ export class FormatString extends Marker { } } -export class Variable extends Marker { +export class Variable extends TransformableMarker { constructor(public name: string) { super(); @@ -392,9 +404,8 @@ export class Variable extends Marker { resolve(resolver: VariableResolver): boolean { let value = resolver.resolve(this); - let [firstChild] = this._children; - if (firstChild instanceof Transform && this._children.length === 1) { - value = firstChild.resolve(value || ''); + if (this.transform) { + value = this.transform.resolve(value || ''); } if (value !== undefined) { this._children = [new Text(value)]; @@ -404,15 +415,22 @@ export class Variable extends Marker { } toTextmateString(): string { + let transformString = ''; + if (this.transform) { + transformString = this.transform.toTextmateString(); + } if (this.children.length === 0) { - return `\${${this.name}}`; + return `\${${this.name}${transformString}}`; } else { - return `\${${this.name}:${this.children.map(child => child.toTextmateString()).join('')}}`; + return `\${${this.name}:${this.children.map(child => child.toTextmateString()).join('')}${transformString}}`; } } clone(): Variable { const ret = new Variable(this.name); + if (this.transform) { + ret.transform = this.transform.clone(); + } ret._children = this.children.map(child => child.clone()); return ret; } @@ -580,6 +598,7 @@ export class SnippetParser { for (const placeholder of incompletePlaceholders) { if (placeholderDefaultValues.has(placeholder.index)) { const clone = new Placeholder(placeholder.index); + clone.transform = placeholder.transform; for (const child of placeholderDefaultValues.get(placeholder.index)) { clone.appendChild(child.clone()); } @@ -696,6 +715,17 @@ export class SnippetParser { return true; } + //..///} -> transform + if (this._accept(TokenType.Forwardslash)) { + if (this._parseTransform(placeholder)) { + parent.appendChild(placeholder); + return true; + } + + this._backTo(token); + return false; + } + if (this._parse(placeholder)) { continue; } @@ -717,11 +747,20 @@ export class SnippetParser { continue; } - if (this._accept(TokenType.Pipe) && this._accept(TokenType.CurlyClose)) { - // ..|} -> done + if (this._accept(TokenType.Pipe)) { placeholder.appendChild(choice); - parent.appendChild(placeholder); - return true; + if (this._accept(TokenType.CurlyClose)) { + // ..|} -> done + parent.appendChild(placeholder); + return true; + } + if (this._accept(TokenType.Forwardslash)) { + // ...|///} -> transform + if (this._parseTransform(placeholder)) { + parent.appendChild(placeholder); + return true; + } + } } } @@ -729,6 +768,16 @@ export class SnippetParser { return false; } + } else if (this._accept(TokenType.Forwardslash)) { + // ${1///} + if (this._parseTransform(placeholder)) { + parent.appendChild(placeholder); + return true; + } + + this._backTo(token); + return false; + } else if (this._accept(TokenType.CurlyClose)) { // ${1} parent.appendChild(placeholder); @@ -829,7 +878,7 @@ export class SnippetParser { } } - private _parseTransform(parent: Variable): boolean { + private _parseTransform(parent: TransformableMarker): boolean { // ...//} let transform = new Transform(); @@ -894,7 +943,7 @@ export class SnippetParser { return false; } - parent.appendChild(transform); + parent.transform = transform; return true; } diff --git a/src/vs/editor/contrib/snippet/snippetSession.ts b/src/vs/editor/contrib/snippet/snippetSession.ts index 31be6b2ba1662..9d9f336c9290e 100644 --- a/src/vs/editor/contrib/snippet/snippetSession.ts +++ b/src/vs/editor/contrib/snippet/snippetSession.ts @@ -87,6 +87,23 @@ export class OneSnippet { this._initDecorations(); + // Transform placeholder text if necessary + if (this._placeholderGroupsIdx >= 0) { + let operations: IIdentifiedSingleEditOperation[] = []; + + for (const placeholder of this._placeholderGroups[this._placeholderGroupsIdx]) { + // Check if the placeholder has a transformation + if (placeholder.transform) { + const id = this._placeholderDecorations.get(placeholder); + const range = this._editor.getModel().getDecorationRange(id); + const currentValue = this._editor.getModel().getValueInRange(range); + + operations.push({ range: range, text: placeholder.transform.resolve(currentValue) }); + } + } + this._editor.getModel().applyEdits(operations); + } + if (fwd === true && this._placeholderGroupsIdx < this._placeholderGroups.length - 1) { this._placeholderGroupsIdx += 1; diff --git a/src/vs/editor/contrib/snippet/test/snippetParser.test.ts b/src/vs/editor/contrib/snippet/test/snippetParser.test.ts index 20d5d3c96af12..5cae847a72615 100644 --- a/src/vs/editor/contrib/snippet/test/snippetParser.test.ts +++ b/src/vs/editor/contrib/snippet/test/snippetParser.test.ts @@ -240,6 +240,35 @@ suite('SnippetParser', () => { }); + test('Parser, placeholder transforms', function () { + assertTextAndMarker('${1///}', '', Placeholder); + assertTextAndMarker('${1/regex/format/gmi}', '', Placeholder); + assertTextAndMarker('${1/([A-Z][a-z])/format/}', '', Placeholder); + + // tricky regex + assertTextAndMarker('${1/m\\/atch/$1/i}', '', Placeholder); + assertMarker('${1/regex\/format/options}', Text); + + // incomplete + assertTextAndMarker('${1///', '${1///', Text); + assertTextAndMarker('${1/regex/format/options', '${1/regex/format/options', Text); + }); + + test('Parser, placeholder with defaults and transformation', () => { + assertTextAndMarker('${1:value/foo/bar/}', 'value', Placeholder); + assertTextAndMarker('${1:bar${2:foo}bar/foo/bar/}', 'barfoobar', Placeholder); + + // incomplete + assertTextAndMarker('${1:bar${2:foobar}/foo/bar/', '${1:barfoobar/foo/bar/', Text, Placeholder, Text); + }); + + test('Parser, placeholder with choice and transformation', () => { + assertTextAndMarker('${1|one,two,three|/foo/bar/}', 'one', Placeholder); + assertTextAndMarker('${1|one|/foo/bar/}', 'one', Placeholder); + assertTextAndMarker('${1|one,two,three,|/foo/bar/}', '${1|one,two,three,|/foo/bar/}', Text); + assertTextAndMarker('${1|one,/foo/bar/', '${1|one,/foo/bar/', Text); + }); + test('No way to escape forward slash in snippet regex #36715', function () { assertMarker('${TM_DIRECTORY/src\\//$1/}', Variable); }); @@ -378,6 +407,36 @@ suite('SnippetParser', () => { assert.ok(marker[0] instanceof Variable); }); + test('Parser, transform example', () => { + let marker = new SnippetParser().parse('${1:name} : ${2:type}${3: :=/\\s:=(.*)/${1:+ :=}${1}/};\n$0'); + let childs = marker.children; + + assert.ok(childs[0] instanceof Placeholder); + assert.equal(childs[0].children.length, 1); + assert.equal(childs[0].children[0].toString(), 'name'); + assert.equal((childs[0]).transform, undefined); + assert.ok(childs[1] instanceof Text); + assert.equal(childs[1].toString(), ' : '); + assert.ok(childs[2] instanceof Placeholder); + assert.equal(childs[2].children.length, 1); + assert.equal(childs[2].children[0].toString(), 'type'); + assert.ok(childs[3] instanceof Placeholder); + assert.equal(childs[3].children.length, 1); + assert.equal(childs[3].children[0].toString(), ' :='); + assert.notEqual((childs[3]).transform, undefined); + let t = (childs[3]).transform; + assert.equal(t.regexp, '/\\s:=(.*)/'); + assert.equal(t.children.length, 2); + assert.ok(t.children[0] instanceof FormatString); + assert.equal((t.children[0]).index, 1); + assert.equal((t.children[0]).ifValue, ' :='); + assert.ok(t.children[1] instanceof FormatString); + assert.equal((t.children[1]).index, 1); + assert.ok(childs[4] instanceof Text); + assert.equal(childs[4].toString(), ';\n'); + + }); + test('Parser, default placeholder values', () => { assertMarker('errorContext: `${1:err}`, error: $1', Text, Placeholder, Text, Placeholder); @@ -393,6 +452,37 @@ suite('SnippetParser', () => { assert.equal(((p2).children[0]), 'err'); }); + test('Parser, default placeholder values and one transform', () => { + + assertMarker('errorContext: `${1:err/err/ok/}`, error: $1', Text, Placeholder, Text, Placeholder); + + const [, p1, , p2] = new SnippetParser().parse('errorContext: `${1:err/err/ok/}`, error:$1').children; + + assert.equal((p1).index, '1'); + assert.equal((p1).children.length, '1'); + assert.equal(((p1).children[0]), 'err'); + assert.notEqual((p1).transform, undefined); + + assert.equal((p2).index, '1'); + assert.equal((p2).children.length, '1'); + assert.equal(((p2).children[0]), 'err'); + assert.equal((p2).transform, undefined); + + assertMarker('errorContext: `${1:err}`, error: ${1/err/ok/}', Text, Placeholder, Text, Placeholder); + + const [, p3, , p4] = new SnippetParser().parse('errorContext: `${1:err}`, error:${1/err/ok/}').children; + + assert.equal((p3).index, '1'); + assert.equal((p3).children.length, '1'); + assert.equal(((p3).children[0]), 'err'); + assert.equal((p3).transform, undefined); + + assert.equal((p4).index, '1'); + assert.equal((p4).children.length, '1'); + assert.equal(((p4).children[0]), 'err'); + assert.notEqual((p4).transform, undefined); + }); + test('Repeated snippet placeholder should always inherit, #31040', function () { assertText('${1:foo}-abc-$1', 'foo-abc-foo'); assertText('${1:foo}-abc-${1}', 'foo-abc-foo'); diff --git a/src/vs/editor/contrib/snippet/test/snippetSession.test.ts b/src/vs/editor/contrib/snippet/test/snippetSession.test.ts index 6c9ea6728db92..8093aa82221b0 100644 --- a/src/vs/editor/contrib/snippet/test/snippetSession.test.ts +++ b/src/vs/editor/contrib/snippet/test/snippetSession.test.ts @@ -434,6 +434,121 @@ suite('SnippetSession', function () { assertSelections(editor, new Selection(1, 6, 1, 25)); }); + test('snippets, transform', function () { + editor.getModel().setValue(''); + editor.setSelection(new Selection(1, 1, 1, 1)); + const session = new SnippetSession(editor, '${1/foo/bar/}$0'); + session.insert(); + assertSelections(editor, new Selection(1, 1, 1, 1)); + + editor.trigger('test', 'type', { text: 'foo' }); + session.next(); + + assert.equal(model.getValue(), 'bar'); + assert.equal(session.isAtLastPlaceholder, true); + assertSelections(editor, new Selection(1, 4, 1, 4)); + }); + + test('snippets, multi placeholder same index one transform', function () { + editor.getModel().setValue(''); + editor.setSelection(new Selection(1, 1, 1, 1)); + const session = new SnippetSession(editor, '$1 baz ${1/foo/bar/}$0'); + session.insert(); + assertSelections(editor, new Selection(1, 1, 1, 1), new Selection(1, 6, 1, 6)); + + editor.trigger('test', 'type', { text: 'foo' }); + session.next(); + + assert.equal(model.getValue(), 'foo baz bar'); + assert.equal(session.isAtLastPlaceholder, true); + assertSelections(editor, new Selection(1, 12, 1, 12)); + }); + + test('snippets, transform example', function () { + editor.getModel().setValue(''); + editor.setSelection(new Selection(1, 1, 1, 1)); + const session = new SnippetSession(editor, '${1:name} : ${2:type}${3: :=/\\s:=(.*)/${1:+ :=}${1}/};\n$0'); + session.insert(); + + assertSelections(editor, new Selection(1, 1, 1, 5)); + editor.trigger('test', 'type', { text: 'clk' }); + session.next(); + + assertSelections(editor, new Selection(1, 7, 1, 11)); + editor.trigger('test', 'type', { text: 'std_logic' }); + session.next(); + + assertSelections(editor, new Selection(1, 16, 1, 19)); + session.next(); + + assert.equal(model.getValue(), 'clk : std_logic;\n'); + assert.equal(session.isAtLastPlaceholder, true); + assertSelections(editor, new Selection(2, 1, 2, 1)); + }); + + test('snippets, transform with indent', function () { + const snippet = [ + 'private readonly ${1} = new Emitter<$2>();', + 'readonly ${1/^_(.*)/$1/}: Event<$2> = this.$1.event;', + '$0' + ].join('\n'); + const expected = [ + '{', + '\tprivate readonly _prop = new Emitter();', + '\treadonly prop: Event = this._prop.event;', + '\t', + '}' + ].join('\n'); + const base = [ + '{', + '\t', + '}' + ].join('\n'); + + editor.getModel().setValue(base); + editor.getModel().updateOptions({ insertSpaces: false }); + editor.setSelection(new Selection(2, 2, 2, 2)); + + const session = new SnippetSession(editor, snippet); + session.insert(); + + assertSelections(editor, new Selection(2, 19, 2, 19), new Selection(3, 11, 3, 11), new Selection(3, 28, 3, 28)); + editor.trigger('test', 'type', { text: '_prop' }); + session.next(); + + assertSelections(editor, new Selection(2, 39, 2, 39), new Selection(3, 23, 3, 23)); + editor.trigger('test', 'type', { text: 'string' }); + session.next(); + + assert.equal(model.getValue(), expected); + assert.equal(session.isAtLastPlaceholder, true); + assertSelections(editor, new Selection(4, 2, 4, 2)); + + }); + + test('snippets, transform example hit if', function () { + editor.getModel().setValue(''); + editor.setSelection(new Selection(1, 1, 1, 1)); + const session = new SnippetSession(editor, '${1:name} : ${2:type}${3: :=/\\s:=(.*)/${1:+ :=}${1}/};\n$0'); + session.insert(); + + assertSelections(editor, new Selection(1, 1, 1, 5)); + editor.trigger('test', 'type', { text: 'clk' }); + session.next(); + + assertSelections(editor, new Selection(1, 7, 1, 11)); + editor.trigger('test', 'type', { text: 'std_logic' }); + session.next(); + + assertSelections(editor, new Selection(1, 16, 1, 19)); + editor.trigger('test', 'type', { text: ' := \'1\'' }); + session.next(); + + assert.equal(model.getValue(), 'clk : std_logic := \'1\';\n'); + assert.equal(session.isAtLastPlaceholder, true); + assertSelections(editor, new Selection(2, 1, 2, 1)); + }); + test('Snippet placeholder index incorrect after using 2+ snippets in a row that each end with a placeholder, #30769', function () { editor.getModel().setValue(''); editor.setSelection(new Selection(1, 1, 1, 1));