Repository: microsoft/vscode (TypeScript)
Date: 2022-03-28 10:24:38+00:00
Task type: Bug Fix

Issue:
insertSnippet can put snippet selections in wrong positions
<!-- ⚠️⚠️ Do Not Delete This! bug_report_template ⚠️⚠️ -->
<!-- Please read our Rules of Conduct: https://opensource.microsoft.com/codeofconduct/ -->
<!-- 🕮 Read our guide about submitting issues: https://github.com/microsoft/vscode/wiki/Submitting-Bugs-and-Suggestions -->
<!-- 🔎 Search existing issues to avoid creating duplicates. -->
<!-- 🧪 Test using the latest Insiders build to see if your issue has already been fixed: https://code.visualstudio.com/insiders/ -->
<!-- 💡 Instead of creating your report here, use 'Report Issue' from the 'Help' menu in VS Code to pre-fill useful information. -->
<!-- 🔧 Launch with `code --disable-extensions` to check. -->
Does this issue occur when all extensions are disabled?: Yes

<!-- 🪓 If you answered No above, use 'Help: Start Extension Bisect' from Command Palette to try to identify the cause. -->
<!-- 📣 Issues caused by an extension need to be reported directly to the extension publisher. The 'Help > Report Issue' dialog can assist with this. -->
- VS Code Version: 1.66.0-insider (Universal) Commit: db0525ff4ba814780e901a5b9e399aaf596b9b20
- OS Version: macOS Big Sur 11.6

Steps to Reproduce:
I created a new extension to test this where I basically use a keybinding to trigger a command inserting a snippet.

Speed is of the essence in a lightweight environment/extension in order to reproduce this so that's why we need the keybinding.

```json
"contributes": {
	"commands": [
		{
			"command": "testextension.helloWorld",
			"title": "Hello World"
		}
	],
	"keybindings":[
		{
			"command": "testextension.helloWorld",
			"key": "ctrl+m"
		}
	]
}
```
```typescript
vscode.commands.registerCommand('testextension.helloWorld', async () => {
	const line = vscode.window.activeTextEditor?.document.lineAt(vscode.window.activeTextEditor?.selection.start);
	const snippet = new vscode.SnippetString("aProperty: aClass<${2:boolean}> = new aClass<${2:boolean}>();\n");
	// I believe, but am not sure, that it's important that the place to insert the snippet on differs from the cursor position.
	await vscode.window.activeTextEditor?.insertSnippet(snippet, new vscode.Position(line?.lineNumber! + 5, 4));	
});
```
in a new file containing
```
{
    text
    more text
    some more text
    even more text
    and some more
    additional
    text
}
```
putting the cursor after the first "text", pressing enter twice quickly followed by crtl+m puts the snippet cursors on "ean> = " and "ean>();" instead of "boolean" and "boolean"
insertSnippet can put snippet selections in wrong positions
<!-- ⚠️⚠️ Do Not Delete This! bug_report_template ⚠️⚠️ -->
<!-- Please read our Rules of Conduct: https://opensource.microsoft.com/codeofconduct/ -->
<!-- 🕮 Read our guide about submitting issues: https://github.com/microsoft/vscode/wiki/Submitting-Bugs-and-Suggestions -->
<!-- 🔎 Search existing issues to avoid creating duplicates. -->
<!-- 🧪 Test using the latest Insiders build to see if your issue has already been fixed: https://code.visualstudio.com/insiders/ -->
<!-- 💡 Instead of creating your report here, use 'Report Issue' from the 'Help' menu in VS Code to pre-fill useful information. -->
<!-- 🔧 Launch with `code --disable-extensions` to check. -->
Does this issue occur when all extensions are disabled?: Yes

<!-- 🪓 If you answered No above, use 'Help: Start Extension Bisect' from Command Palette to try to identify the cause. -->
<!-- 📣 Issues caused by an extension need to be reported directly to the extension publisher. The 'Help > Report Issue' dialog can assist with this. -->
- VS Code Version: 1.66.0-insider (Universal) Commit: db0525ff4ba814780e901a5b9e399aaf596b9b20
- OS Version: macOS Big Sur 11.6

Steps to Reproduce:
I created a new extension to test this where I basically use a keybinding to trigger a command inserting a snippet.

Speed is of the essence in a lightweight environment/extension in order to reproduce this so that's why we need the keybinding.

```json
"contributes": {
	"commands": [
		{
			"command": "testextension.helloWorld",
			"title": "Hello World"
		}
	],
	"keybindings":[
		{
			"command": "testextension.helloWorld",
			"key": "ctrl+m"
		}
	]
}
```
```typescript
vscode.commands.registerCommand('testextension.helloWorld', async () => {
	const line = vscode.window.activeTextEditor?.document.lineAt(vscode.window.activeTextEditor?.selection.start);
	const snippet = new vscode.SnippetString("aProperty: aClass<${2:boolean}> = new aClass<${2:boolean}>();\n");
	// I believe, but am not sure, that it's important that the place to insert the snippet on differs from the cursor position.
	await vscode.window.activeTextEditor?.insertSnippet(snippet, new vscode.Position(line?.lineNumber! + 5, 4));	
});
```
in a new file containing
```
{
    text
    more text
    some more text
    even more text
    and some more
    additional
    text
}
```
putting the cursor after the first "text", pressing enter twice quickly followed by crtl+m puts the snippet cursors on "ean> = " and "ean>();" instead of "boolean" and "boolean"


Issue discussion:
I can reproduce but don't know yet what's happening...
This happens because there is an indent only line. Somehow, it is being cleared when/while the snippet edit is being made. That invalidates the offset we have cached at the start of the snippet session. The four whitespace characters that are being removed are exactly those four character by which the placeholders are shifting. 

From the recording you can see that this only happens when the indentation is cleaned-up (line 4) while the snippet is being inserted. 

https://user-images.githubusercontent.com/1794099/160159158-90906be6-8917-4780-a0f9-17e09ec9677c.mov


/cc @alexdima let's discuss Monday. It seems that I am suffering from some auto-clean on edit behaviour here
I can reproduce but don't know yet what's happening...
This happens because there is an indent only line. Somehow, it is being cleared when/while the snippet edit is being made. That invalidates the offset we have cached at the start of the snippet session. The four whitespace characters that are being removed are exactly those four character by which the placeholders are shifting. 

From the recording you can see that this only happens when the indentation is cleaned-up (line 4) while the snippet is being inserted. 

https://user-images.githubusercontent.com/1794099/160159158-90906be6-8917-4780-a0f9-17e09ec9677c.mov


/cc @alexdima let's discuss Monday. It seems that I am suffering from some auto-clean on edit behaviour here

Solution patch:
diff --git a/src/vs/editor/contrib/snippet/browser/snippetSession.ts b/src/vs/editor/contrib/snippet/browser/snippetSession.ts
--- a/src/vs/editor/contrib/snippet/browser/snippetSession.ts
+++ b/src/vs/editor/contrib/snippet/browser/snippetSession.ts
@@ -15,6 +15,7 @@ import { EditOperation, ISingleEditOperation } from 'vs/editor/common/core/editO
 import { IPosition } from 'vs/editor/common/core/position';
 import { Range } from 'vs/editor/common/core/range';
 import { Selection } from 'vs/editor/common/core/selection';
+import { TextChange } from 'vs/editor/common/core/textChange';
 import { IIdentifiedSingleEditOperation, ITextModel, TrackedRangeStickiness } from 'vs/editor/common/model';
 import { ModelDecorationOptions } from 'vs/editor/common/model/textModel';
 import { OvertypingCapturer } from 'vs/editor/contrib/suggest/browser/suggestOvertypingCapturer';
@@ -27,6 +28,7 @@ export class OneSnippet {
 
 	private _placeholderDecorations?: Map<Placeholder, string>;
 	private _placeholderGroups: Placeholder[][];
+	private _offset: number = -1;
 	_placeholderGroupsIdx: number;
 	_nestingLevel: number = 1;
 
@@ -38,13 +40,18 @@ export class OneSnippet {
 	};
 
 	constructor(
-		private readonly _editor: IActiveCodeEditor, private readonly _snippet: TextmateSnippet,
-		private readonly _offset: number, private readonly _snippetLineLeadingWhitespace: string
+		private readonly _editor: IActiveCodeEditor,
+		private readonly _snippet: TextmateSnippet,
+		private readonly _snippetLineLeadingWhitespace: string
 	) {
 		this._placeholderGroups = groupBy(_snippet.placeholders, Placeholder.compareByIndex);
 		this._placeholderGroupsIdx = -1;
 	}
 
+	public initialize(textChange: TextChange): void {
+		this._offset = textChange.newPosition;
+	}
+
 	dispose(): void {
 		if (this._placeholderDecorations) {
 			this._editor.deltaDecorations([...this._placeholderDecorations.values()], []);
@@ -54,6 +61,10 @@ export class OneSnippet {
 
 	private _initDecorations(): void {
 
+		if (this._offset === -1) {
+			throw new Error(`Snippet not initialized!`);
+		}
+
 		if (this._placeholderDecorations) {
 			// already initialized
 			return;
@@ -245,6 +256,7 @@ export class OneSnippet {
 			// elements from the beginning of the array
 			for (const placeholder of this._placeholderGroups[this._placeholderGroupsIdx]) {
 				const nested = others.shift()!;
+				console.assert(nested._offset !== -1);
 				console.assert(!nested._placeholderDecorations);
 
 				// Massage placeholder-indicies of the nested snippet to be
@@ -405,8 +417,6 @@ export class SnippetSession {
 		const modelBasedVariableResolver = editor.invokeWithinContext(accessor => new ModelBasedVariableResolver(accessor.get(ILabelService), model));
 		const readClipboardText = () => clipboardText;
 
-		let delta = 0;
-
 		// know what text the overwrite[Before|After] extensions
 		// of the primary curser have selected because only when
 		// secondary selections extend to the same text we can grow them
@@ -466,15 +476,13 @@ export class SnippetSession {
 				new RandomBasedVariableResolver,
 			]));
 
-			const offset = model.getOffsetAt(start) + delta;
-			delta += snippet.toString().length - model.getValueLengthInRange(snippetSelection);
-
 			// store snippets with the index of their originating selection.
 			// that ensures the primiary cursor stays primary despite not being
 			// the one with lowest start position
 			edits[idx] = EditOperation.replace(snippetSelection, snippet.toString());
 			edits[idx].identifier = { major: idx, minor: 0 }; // mark the edit so only our undo edits will be used to generate end cursors
-			snippets[idx] = new OneSnippet(editor, snippet, offset, snippetLineLeadingWhitespace);
+			edits[idx]._isTracked = true;
+			snippets[idx] = new OneSnippet(editor, snippet, snippetLineLeadingWhitespace);
 		}
 
 		return { edits, snippets };
@@ -509,12 +517,19 @@ export class SnippetSession {
 		const { edits, snippets } = SnippetSession.createEditsAndSnippets(this._editor, this._template, this._options.overwriteBefore, this._options.overwriteAfter, false, this._options.adjustWhitespace, this._options.clipboardText, this._options.overtypingCapturer);
 		this._snippets = snippets;
 
-		this._editor.executeEdits('snippet', edits, undoEdits => {
+		this._editor.executeEdits('snippet', edits, _undoEdits => {
+			// Sometimes, the text buffer will remove automatic whitespace when doing any edits,
+			// so we need to look only at the undo edits relevant for us.
+			// Our edits have an identifier set so that's how we can distinguish them
+			const undoEdits = _undoEdits.filter(edit => !!edit.identifier);
+			for (let idx = 0; idx < snippets.length; idx++) {
+				snippets[idx].initialize(undoEdits[idx].textChange);
+			}
+
 			if (this._snippets[0].hasPlaceholder) {
 				return this._move(true);
 			} else {
 				return undoEdits
-					.filter(edit => !!edit.identifier) // only use our undo edits
 					.map(edit => Selection.fromPositions(edit.range.getEndPosition()));
 			}
 		});
@@ -528,7 +543,15 @@ export class SnippetSession {
 		this._templateMerges.push([this._snippets[0]._nestingLevel, this._snippets[0]._placeholderGroupsIdx, template]);
 		const { edits, snippets } = SnippetSession.createEditsAndSnippets(this._editor, template, options.overwriteBefore, options.overwriteAfter, true, options.adjustWhitespace, options.clipboardText, options.overtypingCapturer);
 
-		this._editor.executeEdits('snippet', edits, undoEdits => {
+		this._editor.executeEdits('snippet', edits, _undoEdits => {
+			// Sometimes, the text buffer will remove automatic whitespace when doing any edits,
+			// so we need to look only at the undo edits relevant for us.
+			// Our edits have an identifier set so that's how we can distinguish them
+			const undoEdits = _undoEdits.filter(edit => !!edit.identifier);
+			for (let idx = 0; idx < snippets.length; idx++) {
+				snippets[idx].initialize(undoEdits[idx].textChange);
+			}
+
 			for (const snippet of this._snippets) {
 				snippet.merge(snippets);
 			}
@@ -539,7 +562,6 @@ export class SnippetSession {
 			} else {
 				return (
 					undoEdits
-						.filter(edit => !!edit.identifier) // only use our undo edits
 						.map(edit => Selection.fromPositions(edit.range.getEndPosition()))
 				);
 			}


Test patch:
diff --git a/src/vs/editor/contrib/snippet/test/browser/snippetController2.test.ts b/src/vs/editor/contrib/snippet/test/browser/snippetController2.test.ts
--- a/src/vs/editor/contrib/snippet/test/browser/snippetController2.test.ts
+++ b/src/vs/editor/contrib/snippet/test/browser/snippetController2.test.ts
@@ -454,6 +454,25 @@ suite('SnippetController2', function () {
 		assertSelections(editor, new Selection(2, 8, 2, 8));
 	});
 
+	test('issue #145727: insertSnippet can put snippet selections in wrong positions (1 of 2)', function () {
+		const ctrl = instaService.createInstance(SnippetController2, editor);
+		model.setValue('');
+		CoreEditingCommands.Tab.runEditorCommand(null, editor, null);
+
+		ctrl.insert('\naProperty: aClass<${2:boolean}> = new aClass<${2:boolean}>();\n', { adjustWhitespace: false });
+		assertSelections(editor, new Selection(2, 19, 2, 26), new Selection(2, 41, 2, 48));
+	});
+
+	test('issue #145727: insertSnippet can put snippet selections in wrong positions (2 of 2)', function () {
+		const ctrl = instaService.createInstance(SnippetController2, editor);
+		model.setValue('');
+		CoreEditingCommands.Tab.runEditorCommand(null, editor, null);
+
+		ctrl.insert('\naProperty: aClass<${2:boolean}> = new aClass<${2:boolean}>();\n');
+		// This will insert \n    aProperty....
+		assertSelections(editor, new Selection(2, 23, 2, 30), new Selection(2, 45, 2, 52));
+	});
+
 	test('leading TAB by snippets won\'t replace by spaces #101870', function () {
 		const ctrl = instaService.createInstance(SnippetController2, editor);
 		model.setValue('');