Repository: microsoft/vscode (TypeScript)
Date: 2022-03-17 15:35:34+00:00
Task type: Bug Fix

Issue:
Typing a quote using US Intl PC keyboard layout always surrounds words
Extracted from discussion re #144590 with @lszomoru 

* on macOS
* switch to the "US International - PC" keyboard layout
* open a TS file with the content:
```ts
const hello = 3;
```
* select hello and press <kbd>'</kbd>, <kbd>u</kbd>
* you end up with:
```ts
const 'hello' = 3;
```
* instead of:
```ts
const ú = 3;
```


https://user-images.githubusercontent.com/5047891/157284013-465ffff9-26f2-4cbe-90a9-04fa36f5b97b.mp4



fyi @hediet


Issue discussion:

Solution patch:
diff --git a/src/vs/editor/common/commands/surroundSelectionCommand.ts b/src/vs/editor/common/commands/surroundSelectionCommand.ts
--- a/src/vs/editor/common/commands/surroundSelectionCommand.ts
+++ b/src/vs/editor/common/commands/surroundSelectionCommand.ts
@@ -4,6 +4,7 @@
  *--------------------------------------------------------------------------------------------*/
 
 import { Range } from 'vs/editor/common/core/range';
+import { Position } from 'vs/editor/common/core/position';
 import { Selection } from 'vs/editor/common/core/selection';
 import { ICommand, ICursorStateComputerData, IEditOperationBuilder } from 'vs/editor/common/editorCommon';
 import { ITextModel } from 'vs/editor/common/model';
@@ -48,3 +49,36 @@ export class SurroundSelectionCommand implements ICommand {
 		);
 	}
 }
+
+/**
+ * A surround selection command that runs after composition finished.
+ */
+export class CompositionSurroundSelectionCommand implements ICommand {
+
+	constructor(
+		private readonly _position: Position,
+		private readonly _text: string,
+		private readonly _charAfter: string
+	) { }
+
+	public getEditOperations(model: ITextModel, builder: IEditOperationBuilder): void {
+		builder.addTrackedEditOperation(new Range(
+			this._position.lineNumber,
+			this._position.column,
+			this._position.lineNumber,
+			this._position.column
+		), this._text + this._charAfter);
+	}
+
+	public computeCursorState(model: ITextModel, helper: ICursorStateComputerData): Selection {
+		const inverseEditOperations = helper.getInverseEditOperations();
+		const opRange = inverseEditOperations[0].range;
+
+		return new Selection(
+			opRange.endLineNumber,
+			opRange.startColumn,
+			opRange.endLineNumber,
+			opRange.endColumn - this._charAfter.length
+		);
+	}
+}
diff --git a/src/vs/editor/common/cursor/cursor.ts b/src/vs/editor/common/cursor/cursor.ts
--- a/src/vs/editor/common/cursor/cursor.ts
+++ b/src/vs/editor/common/cursor/cursor.ts
@@ -10,7 +10,7 @@ import { CursorConfiguration, CursorState, EditOperationResult, EditOperationTyp
 import { CursorContext } from 'vs/editor/common/cursor/cursorContext';
 import { DeleteOperations } from 'vs/editor/common/cursor/cursorDeleteOperations';
 import { CursorChangeReason } from 'vs/editor/common/cursorEvents';
-import { TypeOperations, TypeWithAutoClosingCommand } from 'vs/editor/common/cursor/cursorTypeOperations';
+import { CompositionOutcome, TypeOperations, TypeWithAutoClosingCommand } from 'vs/editor/common/cursor/cursorTypeOperations';
 import { Position } from 'vs/editor/common/core/position';
 import { Range, IRange } from 'vs/editor/common/core/range';
 import { ISelection, Selection, SelectionDirection } from 'vs/editor/common/core/selection';
@@ -528,17 +528,17 @@ export class CursorsController extends Disposable {
 	}
 
 	public startComposition(eventsCollector: ViewModelEventsCollector): void {
-		this._compositionState = new CompositionState(this._model, this.getSelection());
+		this._compositionState = new CompositionState(this._model, this.getSelections());
 	}
 
 	public endComposition(eventsCollector: ViewModelEventsCollector, source?: string | null | undefined): void {
-		const compositionInsertText = this._compositionState ? this._compositionState.deduceInsertedText(this._model, this.getSelection()) : null;
+		const compositionOutcome = this._compositionState ? this._compositionState.deduceOutcome(this._model, this.getSelections()) : null;
 		this._compositionState = null;
 
 		this._executeEdit(() => {
 			if (source === 'keyboard') {
 				// composition finishes, let's check if we need to auto complete if necessary.
-				this._executeEditOperation(TypeOperations.compositionEndWithInterceptors(this._prevEditOperationType, this.context.cursorConfig, this._model, compositionInsertText, this.getSelections(), this.getAutoClosedCharacters()));
+				this._executeEditOperation(TypeOperations.compositionEndWithInterceptors(this._prevEditOperationType, this.context.cursorConfig, this._model, compositionOutcome, this.getSelections(), this.getAutoClosedCharacters()));
 			}
 		}, eventsCollector, source);
 	}
@@ -1013,49 +1013,76 @@ class CommandExecutor {
 class CompositionLineState {
 	constructor(
 		public readonly text: string,
-		public readonly pos: number
+		public readonly startSelection: number,
+		public readonly endSelection: number
 	) { }
 }
 
 class CompositionState {
 
-	private readonly _original: CompositionLineState | null;
+	private readonly _original: CompositionLineState[] | null;
 
-	private static _capture(textModel: ITextModel, selection: Selection): CompositionLineState | null {
-		if (!selection.isEmpty()) {
-			return null;
+	private static _capture(textModel: ITextModel, selections: Selection[]): CompositionLineState[] | null {
+		const result: CompositionLineState[] = [];
+		for (const selection of selections) {
+			if (selection.startLineNumber !== selection.endLineNumber) {
+				return null;
+			}
+			result.push(new CompositionLineState(
+				textModel.getLineContent(selection.startLineNumber),
+				selection.startColumn - 1,
+				selection.endColumn - 1
+			));
 		}
-		return new CompositionLineState(
-			textModel.getLineContent(selection.startLineNumber),
-			selection.startColumn - 1
-		);
+		return result;
 	}
 
-	constructor(textModel: ITextModel, selection: Selection) {
-		this._original = CompositionState._capture(textModel, selection);
+	constructor(textModel: ITextModel, selections: Selection[]) {
+		this._original = CompositionState._capture(textModel, selections);
 	}
 
 	/**
 	 * Returns the inserted text during this composition.
 	 * If the composition resulted in existing text being changed (i.e. not a pure insertion) it returns null.
 	 */
-	deduceInsertedText(textModel: ITextModel, selection: Selection): string | null {
+	deduceOutcome(textModel: ITextModel, selections: Selection[]): CompositionOutcome[] | null {
 		if (!this._original) {
 			return null;
 		}
-		const current = CompositionState._capture(textModel, selection);
+		const current = CompositionState._capture(textModel, selections);
 		if (!current) {
 			return null;
 		}
-		const insertedTextLength = current.text.length - this._original.text.length;
-		if (insertedTextLength < 0) {
+		if (this._original.length !== current.length) {
 			return null;
 		}
-		const originalPrefix = this._original.text.substring(0, this._original.pos);
-		const originalSuffix = this._original.text.substring(this._original.pos);
-		if (!current.text.startsWith(originalPrefix) || !current.text.endsWith(originalSuffix)) {
-			return null;
+		const result: CompositionOutcome[] = [];
+		for (let i = 0, len = this._original.length; i < len; i++) {
+			result.push(CompositionState._deduceOutcome(this._original[i], current[i]));
 		}
-		return current.text.substring(this._original.pos, this._original.pos + insertedTextLength);
+		return result;
+	}
+
+	private static _deduceOutcome(original: CompositionLineState, current: CompositionLineState): CompositionOutcome {
+		const commonPrefix = Math.min(
+			original.startSelection,
+			current.startSelection,
+			strings.commonPrefixLength(original.text, current.text)
+		);
+		const commonSuffix = Math.min(
+			original.text.length - original.endSelection,
+			current.text.length - current.endSelection,
+			strings.commonSuffixLength(original.text, current.text)
+		);
+		const deletedText = original.text.substring(commonPrefix, original.text.length - commonSuffix);
+		const insertedText = current.text.substring(commonPrefix, current.text.length - commonSuffix);
+		return new CompositionOutcome(
+			deletedText,
+			original.startSelection - commonPrefix,
+			original.endSelection - commonPrefix,
+			insertedText,
+			current.startSelection - commonPrefix,
+			current.endSelection - commonPrefix
+		);
 	}
 }
diff --git a/src/vs/editor/common/cursor/cursorTypeOperations.ts b/src/vs/editor/common/cursor/cursorTypeOperations.ts
--- a/src/vs/editor/common/cursor/cursorTypeOperations.ts
+++ b/src/vs/editor/common/cursor/cursorTypeOperations.ts
@@ -8,7 +8,7 @@ import { onUnexpectedError } from 'vs/base/common/errors';
 import * as strings from 'vs/base/common/strings';
 import { ReplaceCommand, ReplaceCommandWithOffsetCursorState, ReplaceCommandWithoutChangingPosition, ReplaceCommandThatPreservesSelection } from 'vs/editor/common/commands/replaceCommand';
 import { ShiftCommand } from 'vs/editor/common/commands/shiftCommand';
-import { SurroundSelectionCommand } from 'vs/editor/common/commands/surroundSelectionCommand';
+import { CompositionSurroundSelectionCommand, SurroundSelectionCommand } from 'vs/editor/common/commands/surroundSelectionCommand';
 import { CursorConfiguration, EditOperationResult, EditOperationType, ICursorSimpleModel, isQuote } from 'vs/editor/common/cursorCommon';
 import { WordCharacterClass, getMapForWordSeparators } from 'vs/editor/common/core/wordCharacterClassifier';
 import { Range } from 'vs/editor/common/core/range';
@@ -704,8 +704,7 @@ export class TypeOperations {
 
 		const isTypingAQuoteCharacter = isQuote(ch);
 
-		for (let i = 0, len = selections.length; i < len; i++) {
-			const selection = selections[i];
+		for (const selection of selections) {
 
 			if (selection.isEmpty()) {
 				return false;
@@ -821,13 +820,82 @@ export class TypeOperations {
 	/**
 	 * This is very similar with typing, but the character is already in the text buffer!
 	 */
-	public static compositionEndWithInterceptors(prevEditOperationType: EditOperationType, config: CursorConfiguration, model: ITextModel, compositionInsertText: string | null, selections: Selection[], autoClosedCharacters: Range[]): EditOperationResult | null {
-		if (!compositionInsertText || compositionInsertText.length === 0) {
-			// no content was typed or composition was not a pure insertion
+	public static compositionEndWithInterceptors(prevEditOperationType: EditOperationType, config: CursorConfiguration, model: ITextModel, compositions: CompositionOutcome[] | null, selections: Selection[], autoClosedCharacters: Range[]): EditOperationResult | null {
+		if (!compositions) {
+			// could not deduce what the composition did
 			return null;
 		}
 
-		const ch = compositionInsertText.charAt(compositionInsertText.length - 1);
+		let insertedText: string | null = null;
+		for (const composition of compositions) {
+			if (insertedText === null) {
+				insertedText = composition.insertedText;
+			} else if (insertedText !== composition.insertedText) {
+				// not all selections agree on what was typed
+				return null;
+			}
+		}
+
+		if (!insertedText || insertedText.length !== 1) {
+			// we're only interested in the case where a single character was inserted
+			return null;
+		}
+
+		const ch = insertedText;
+
+		let hasDeletion = false;
+		for (const composition of compositions) {
+			if (composition.deletedText.length !== 0) {
+				hasDeletion = true;
+				break;
+			}
+		}
+
+		if (hasDeletion) {
+			// Check if this could have been a surround selection
+
+			if (!TypeOperations._shouldSurroundChar(config, ch) || !config.surroundingPairs.hasOwnProperty(ch)) {
+				return null;
+			}
+
+			const isTypingAQuoteCharacter = isQuote(ch);
+
+			for (const composition of compositions) {
+				if (composition.deletedSelectionStart !== 0 || composition.deletedSelectionEnd !== composition.deletedText.length) {
+					// more text was deleted than was selected, so this could not have been a surround selection
+					return null;
+				}
+				if (/^[ \t]+$/.test(composition.deletedText)) {
+					// deleted text was only whitespace
+					return null;
+				}
+				if (isTypingAQuoteCharacter && isQuote(composition.deletedText)) {
+					// deleted text was a quote
+					return null;
+				}
+			}
+
+			const positions: Position[] = [];
+			for (const selection of selections) {
+				if (!selection.isEmpty()) {
+					return null;
+				}
+				positions.push(selection.getPosition());
+			}
+
+			if (positions.length !== compositions.length) {
+				return null;
+			}
+
+			const commands: ICommand[] = [];
+			for (let i = 0, len = positions.length; i < len; i++) {
+				commands.push(new CompositionSurroundSelectionCommand(positions[i], compositions[i].deletedText, ch));
+			}
+			return new EditOperationResult(EditOperationType.TypingOther, commands, {
+				shouldPushStackElementBefore: true,
+				shouldPushStackElementAfter: false
+			});
+		}
 
 		if (this._isAutoClosingOvertype(config, model, selections, autoClosedCharacters, ch)) {
 			// Unfortunately, the close character is at this point "doubled", so we need to delete it...
@@ -888,7 +956,7 @@ export class TypeOperations {
 			}
 		}
 
-		if (this._isSurroundSelectionType(config, model, selections, ch)) {
+		if (!isDoingComposition && this._isSurroundSelectionType(config, model, selections, ch)) {
 			return this._runSurroundSelectionType(prevEditOperationType, config, model, selections, ch);
 		}
 
@@ -994,6 +1062,17 @@ export class TypeWithAutoClosingCommand extends ReplaceCommandWithOffsetCursorSt
 	}
 }
 
+export class CompositionOutcome {
+	constructor(
+		public readonly deletedText: string,
+		public readonly deletedSelectionStart: number,
+		public readonly deletedSelectionEnd: number,
+		public readonly insertedText: string,
+		public readonly insertedSelectionStart: number,
+		public readonly insertedSelectionEnd: number,
+	) { }
+}
+
 function getTypingOperation(typedText: string, previousTypingOperation: EditOperationType): EditOperationType {
 	if (typedText === ' ') {
 		return previousTypingOperation === EditOperationType.TypingFirstSpace


Test patch:
diff --git a/src/vs/editor/test/browser/controller/cursor.test.ts b/src/vs/editor/test/browser/controller/cursor.test.ts
--- a/src/vs/editor/test/browser/controller/cursor.test.ts
+++ b/src/vs/editor/test/browser/controller/cursor.test.ts
@@ -6081,6 +6081,29 @@ suite('autoClosingPairs', () => {
 		mode.dispose();
 	});
 
+	test('issue #144693: Typing a quote using US Intl PC keyboard layout always surrounds words', () => {
+		const mode = new AutoClosingMode();
+		usingCursor({
+			text: [
+				'const hello = 3;'
+			],
+			languageId: mode.languageId
+		}, (editor, model, viewModel) => {
+			viewModel.setSelections('test', [new Selection(1, 7, 1, 12)]);
+
+			// Pressing ' + e
+
+			viewModel.startComposition();
+			viewModel.type(`'`, 'keyboard');
+			viewModel.compositionType(`é`, 1, 0, 0, 'keyboard');
+			viewModel.compositionType(`é`, 1, 0, 0, 'keyboard');
+			viewModel.endComposition('keyboard');
+
+			assert.strictEqual(model.getValue(), `const é = 3;`);
+		});
+		mode.dispose();
+	});
+
 	test('issue #82701: auto close does not execute when IME is canceled via backspace', () => {
 		let mode = new AutoClosingMode();
 		usingCursor({