Repository: microsoft/vscode (TypeScript)
Date: 2020-11-06 16:35:03+00:00
Task type: Bug Fix

Issue:
auto closing pairs with conflicting patterns problems
```
Version: 1.33.1 (user setup)
Commit: 51b0b28134d51361cf996d2f0a1c698247aeabd8
Date: 2019-04-11T08:27:14.102Z
Electron: 3.1.6
Chrome: 66.0.3359.181
Node.js: 10.2.0
V8: 6.6.346.32
OS: Windows_NT x64 10.0.17763
```

Steps to Reproduce:

1. Create two auto closing pairs in a language configuration file,
```JSON
	"autoClosingPairs": [
		{"open": "(", "close": ")"},
		{"open": "(*", "close": "*)",  "notIn": ["string"]},
	],
```
2. trying using the two character auto closing pair, `(*` and you will get `(**))`.

On the other hand, if you remove the ending ')' from the closing '*)' you almost get normal function, except that in cases where `(` doesn't auto close with `)`, you get `(**`.

Note this condition exists in #57838, in a reference to the Structured Text Language, though it is not shown in the example on that feature request.  Reference the repository https://github.com/Serhioromano/vscode-st for some example.

I think the Auto Closing logic needs to consider when auto closing pairs might conflict with each other.  In this case, '(**)' overlaps with '()'.



Issue discussion:
Another example is `[<SomeAttribute>]` in F#, where typing `[<` produces `>]]` where the closing bracket is doubled up because `[]` is also a bracket.

![Sample of doubled brackets](https://user-images.githubusercontent.com/90762/63142401-de6b5b00-c013-11e9-89ad-140a7152fb30.gif)

As you can see, the first `[` creates a matching `]`. Then when the `<` is typed, which is the second character of `[<`, the matching `>]` is inserted without considering the fact that there's already a `]` present in the file, which results in a doubled-up right bracket: `[<>]]` instead of `[<>]`.

This also happens with most F# tokens that are two or more characters, e.g. array syntax `[||]`, anonymous record syntax `{||}`, comment block syntax `(**)`, and so on.
<!-- 6d457af9-96bd-47a8-a0e8-ecf120dfffc1 --> 

 This feature request is now a candidate for our backlog. The community has 60 days to upvote the issue. If it receives 20 upvotes we will move it to our backlog. If not, we will close it. To learn more about how we handle feature requests, please see our [documentation](https://aka.ms/vscode-issue-lifecycle).

Happy Coding!
<!-- 9078ab2c-c9e0-7adb-d31b-1f23430222f4 --> 

 :slightly_smiling_face: This feature request received a sufficient number of community upvotes and we moved it to our backlog. To learn more about how we handle feature requests, please see our [documentation](https://aka.ms/vscode-issue-lifecycle).

Happy Coding!

Solution patch:
diff --git a/src/vs/editor/common/controller/cursor.ts b/src/vs/editor/common/controller/cursor.ts
--- a/src/vs/editor/common/controller/cursor.ts
+++ b/src/vs/editor/common/controller/cursor.ts
@@ -531,7 +531,7 @@ export class Cursor extends Disposable {
 			}
 			const closeChar = m[1];
 
-			const autoClosingPairsCandidates = this.context.cursorConfig.autoClosingPairsClose2.get(closeChar);
+			const autoClosingPairsCandidates = this.context.cursorConfig.autoClosingPairs.autoClosingPairsCloseSingleChar.get(closeChar);
 			if (!autoClosingPairsCandidates || autoClosingPairsCandidates.length !== 1) {
 				return null;
 			}
diff --git a/src/vs/editor/common/controller/cursorCommon.ts b/src/vs/editor/common/controller/cursorCommon.ts
--- a/src/vs/editor/common/controller/cursorCommon.ts
+++ b/src/vs/editor/common/controller/cursorCommon.ts
@@ -14,7 +14,7 @@ import { ICommand, IConfiguration } from 'vs/editor/common/editorCommon';
 import { ITextModel, TextModelResolvedOptions } from 'vs/editor/common/model';
 import { TextModel } from 'vs/editor/common/model/textModel';
 import { LanguageIdentifier } from 'vs/editor/common/modes';
-import { IAutoClosingPair, StandardAutoClosingPairConditional } from 'vs/editor/common/modes/languageConfiguration';
+import { AutoClosingPairs, IAutoClosingPair } from 'vs/editor/common/modes/languageConfiguration';
 import { LanguageConfigurationRegistry } from 'vs/editor/common/modes/languageConfigurationRegistry';
 import { ICoordinatesConverter } from 'vs/editor/common/viewModel/viewModel';
 import { Constants } from 'vs/base/common/uint';
@@ -75,8 +75,7 @@ export class CursorConfiguration {
 	public readonly autoClosingOvertype: EditorAutoClosingOvertypeStrategy;
 	public readonly autoSurround: EditorAutoSurroundStrategy;
 	public readonly autoIndent: EditorAutoIndentStrategy;
-	public readonly autoClosingPairsOpen2: Map<string, StandardAutoClosingPairConditional[]>;
-	public readonly autoClosingPairsClose2: Map<string, StandardAutoClosingPairConditional[]>;
+	public readonly autoClosingPairs: AutoClosingPairs;
 	public readonly surroundingPairs: CharacterMap;
 	public readonly shouldAutoCloseBefore: { quote: (ch: string) => boolean, bracket: (ch: string) => boolean };
 
@@ -136,9 +135,7 @@ export class CursorConfiguration {
 			bracket: CursorConfiguration._getShouldAutoClose(languageIdentifier, this.autoClosingBrackets)
 		};
 
-		const autoClosingPairs = LanguageConfigurationRegistry.getAutoClosingPairs(languageIdentifier.id);
-		this.autoClosingPairsOpen2 = autoClosingPairs.autoClosingPairsOpen;
-		this.autoClosingPairsClose2 = autoClosingPairs.autoClosingPairsClose;
+		this.autoClosingPairs = LanguageConfigurationRegistry.getAutoClosingPairs(languageIdentifier.id);
 
 		let surroundingPairs = CursorConfiguration._getSurroundingPairs(languageIdentifier);
 		if (surroundingPairs) {
diff --git a/src/vs/editor/common/controller/cursorDeleteOperations.ts b/src/vs/editor/common/controller/cursorDeleteOperations.ts
--- a/src/vs/editor/common/controller/cursorDeleteOperations.ts
+++ b/src/vs/editor/common/controller/cursorDeleteOperations.ts
@@ -122,7 +122,7 @@ export class DeleteOperations {
 
 	public static deleteLeft(prevEditOperationType: EditOperationType, config: CursorConfiguration, model: ICursorSimpleModel, selections: Selection[]): [boolean, Array<ICommand | null>] {
 
-		if (this.isAutoClosingPairDelete(config.autoClosingBrackets, config.autoClosingQuotes, config.autoClosingPairsOpen2, model, selections)) {
+		if (this.isAutoClosingPairDelete(config.autoClosingBrackets, config.autoClosingQuotes, config.autoClosingPairs.autoClosingPairsOpenByEnd, model, selections)) {
 			return this._runAutoClosingPairDelete(config, model, selections);
 		}
 
diff --git a/src/vs/editor/common/controller/cursorTypeOperations.ts b/src/vs/editor/common/controller/cursorTypeOperations.ts
--- a/src/vs/editor/common/controller/cursorTypeOperations.ts
+++ b/src/vs/editor/common/controller/cursorTypeOperations.ts
@@ -439,7 +439,7 @@ export class TypeOperations {
 			return false;
 		}
 
-		if (!config.autoClosingPairsClose2.has(ch)) {
+		if (!config.autoClosingPairs.autoClosingPairsCloseSingleChar.has(ch)) {
 			return false;
 		}
 
@@ -498,31 +498,20 @@ export class TypeOperations {
 		});
 	}
 
-	private static _autoClosingPairIsSymmetric(autoClosingPair: StandardAutoClosingPairConditional): boolean {
-		const { open, close } = autoClosingPair;
-		return (open.indexOf(close) >= 0 || close.indexOf(open) >= 0);
-	}
-
-	private static _isBeforeClosingBrace(config: CursorConfiguration, autoClosingPair: StandardAutoClosingPairConditional, characterAfter: string) {
-		const otherAutoClosingPairs = config.autoClosingPairsClose2.get(characterAfter);
-		if (!otherAutoClosingPairs) {
-			return false;
-		}
+	private static _isBeforeClosingBrace(config: CursorConfiguration, lineAfter: string) {
+		// If the start of lineAfter can be interpretted as both a starting or ending brace, default to returning false
+		const nextChar = lineAfter.charAt(0);
+		const potentialStartingBraces = config.autoClosingPairs.autoClosingPairsOpenByStart.get(nextChar) || [];
+		const potentialClosingBraces = config.autoClosingPairs.autoClosingPairsCloseByStart.get(nextChar) || [];
 
-		const thisBraceIsSymmetric = TypeOperations._autoClosingPairIsSymmetric(autoClosingPair);
-		for (const otherAutoClosingPair of otherAutoClosingPairs) {
-			const otherBraceIsSymmetric = TypeOperations._autoClosingPairIsSymmetric(otherAutoClosingPair);
-			if (!thisBraceIsSymmetric && otherBraceIsSymmetric) {
-				continue;
-			}
-			return true;
-		}
+		const isBeforeStartingBrace = potentialStartingBraces.some(x => lineAfter.startsWith(x.open));
+		const isBeforeClosingBrace = potentialClosingBraces.some(x => lineAfter.startsWith(x.close));
 
-		return false;
+		return !isBeforeStartingBrace && isBeforeClosingBrace;
 	}
 
 	private static _findAutoClosingPairOpen(config: CursorConfiguration, model: ITextModel, positions: Position[], ch: string): StandardAutoClosingPairConditional | null {
-		const autoClosingPairCandidates = config.autoClosingPairsOpen2.get(ch);
+		const autoClosingPairCandidates = config.autoClosingPairs.autoClosingPairsOpenByEnd.get(ch);
 		if (!autoClosingPairCandidates) {
 			return null;
 		}
@@ -548,7 +537,29 @@ export class TypeOperations {
 		return autoClosingPair;
 	}
 
-	private static _isAutoClosingOpenCharType(config: CursorConfiguration, model: ITextModel, selections: Selection[], ch: string, insertOpenCharacter: boolean): StandardAutoClosingPairConditional | null {
+	private static _findSubAutoClosingPairClose(config: CursorConfiguration, autoClosingPair: StandardAutoClosingPairConditional): string {
+		if (autoClosingPair.open.length <= 1) {
+			return '';
+		}
+		const lastChar = autoClosingPair.close.charAt(autoClosingPair.close.length - 1);
+		// get candidates with the same last character as close
+		const subPairCandidates = config.autoClosingPairs.autoClosingPairsCloseByEnd.get(lastChar) || [];
+		let subPairMatch: StandardAutoClosingPairConditional | null = null;
+		for (const x of subPairCandidates) {
+			if (x.open !== autoClosingPair.open && autoClosingPair.open.includes(x.open) && autoClosingPair.close.endsWith(x.close)) {
+				if (!subPairMatch || x.open.length > subPairMatch.open.length) {
+					subPairMatch = x;
+				}
+			}
+		}
+		if (subPairMatch) {
+			return subPairMatch.close;
+		} else {
+			return '';
+		}
+	}
+
+	private static _getAutoClosingPairClose(config: CursorConfiguration, model: ITextModel, selections: Selection[], ch: string, insertOpenCharacter: boolean): string | null {
 		const chIsQuote = isQuote(ch);
 		const autoCloseConfig = chIsQuote ? config.autoClosingQuotes : config.autoClosingBrackets;
 		if (autoCloseConfig === 'never') {
@@ -560,6 +571,9 @@ export class TypeOperations {
 			return null;
 		}
 
+		const subAutoClosingPairClose = this._findSubAutoClosingPairClose(config, autoClosingPair);
+		let isSubAutoClosingPairPresent = true;
+
 		const shouldAutoCloseBefore = chIsQuote ? config.shouldAutoCloseBefore.quote : config.shouldAutoCloseBefore.bracket;
 
 		for (let i = 0, len = selections.length; i < len; i++) {
@@ -570,11 +584,16 @@ export class TypeOperations {
 
 			const position = selection.getPosition();
 			const lineText = model.getLineContent(position.lineNumber);
+			const lineAfter = lineText.substring(position.column - 1);
 
-			// Only consider auto closing the pair if a space follows or if another autoclosed pair follows
+			if (!lineAfter.startsWith(subAutoClosingPairClose)) {
+				isSubAutoClosingPairPresent = false;
+			}
+
+			// Only consider auto closing the pair if an allowed character follows or if another autoclosed pair closing brace follows
 			if (lineText.length > position.column - 1) {
 				const characterAfter = lineText.charAt(position.column - 1);
-				const isBeforeCloseBrace = TypeOperations._isBeforeClosingBrace(config, autoClosingPair, characterAfter);
+				const isBeforeCloseBrace = TypeOperations._isBeforeClosingBrace(config, lineAfter);
 
 				if (!isBeforeCloseBrace && !shouldAutoCloseBefore(characterAfter)) {
 					return null;
@@ -612,14 +631,18 @@ export class TypeOperations {
 			}
 		}
 
-		return autoClosingPair;
+		if (isSubAutoClosingPairPresent) {
+			return autoClosingPair.close.substring(0, autoClosingPair.close.length - subAutoClosingPairClose.length);
+		} else {
+			return autoClosingPair.close;
+		}
 	}
 
-	private static _runAutoClosingOpenCharType(prevEditOperationType: EditOperationType, config: CursorConfiguration, model: ITextModel, selections: Selection[], ch: string, insertOpenCharacter: boolean, autoClosingPair: StandardAutoClosingPairConditional): EditOperationResult {
+	private static _runAutoClosingOpenCharType(prevEditOperationType: EditOperationType, config: CursorConfiguration, model: ITextModel, selections: Selection[], ch: string, insertOpenCharacter: boolean, autoClosingPairClose: string): EditOperationResult {
 		let commands: ICommand[] = [];
 		for (let i = 0, len = selections.length; i < len; i++) {
 			const selection = selections[i];
-			commands[i] = new TypeWithAutoClosingCommand(selection, ch, insertOpenCharacter, autoClosingPair.close);
+			commands[i] = new TypeWithAutoClosingCommand(selection, ch, insertOpenCharacter, autoClosingPairClose);
 		}
 		return new EditOperationResult(EditOperationType.Typing, commands, {
 			shouldPushStackElementBefore: true,
@@ -794,9 +817,9 @@ export class TypeOperations {
 			});
 		}
 
-		const autoClosingPairOpenCharType = this._isAutoClosingOpenCharType(config, model, selections, ch, false);
-		if (autoClosingPairOpenCharType) {
-			return this._runAutoClosingOpenCharType(prevEditOperationType, config, model, selections, ch, false, autoClosingPairOpenCharType);
+		const autoClosingPairClose = this._getAutoClosingPairClose(config, model, selections, ch, false);
+		if (autoClosingPairClose !== null) {
+			return this._runAutoClosingOpenCharType(prevEditOperationType, config, model, selections, ch, false, autoClosingPairClose);
 		}
 
 		return null;
@@ -838,9 +861,9 @@ export class TypeOperations {
 		}
 
 		if (!isDoingComposition) {
-			const autoClosingPairOpenCharType = this._isAutoClosingOpenCharType(config, model, selections, ch, true);
-			if (autoClosingPairOpenCharType) {
-				return this._runAutoClosingOpenCharType(prevEditOperationType, config, model, selections, ch, true, autoClosingPairOpenCharType);
+			const autoClosingPairClose = this._getAutoClosingPairClose(config, model, selections, ch, true);
+			if (autoClosingPairClose) {
+				return this._runAutoClosingOpenCharType(prevEditOperationType, config, model, selections, ch, true, autoClosingPairClose);
 			}
 		}
 
diff --git a/src/vs/editor/common/controller/cursorWordOperations.ts b/src/vs/editor/common/controller/cursorWordOperations.ts
--- a/src/vs/editor/common/controller/cursorWordOperations.ts
+++ b/src/vs/editor/common/controller/cursorWordOperations.ts
@@ -384,7 +384,7 @@ export class WordOperations {
 			return selection;
 		}
 
-		if (DeleteOperations.isAutoClosingPairDelete(ctx.autoClosingBrackets, ctx.autoClosingQuotes, ctx.autoClosingPairs.autoClosingPairsOpen, ctx.model, [ctx.selection])) {
+		if (DeleteOperations.isAutoClosingPairDelete(ctx.autoClosingBrackets, ctx.autoClosingQuotes, ctx.autoClosingPairs.autoClosingPairsOpenByEnd, ctx.model, [ctx.selection])) {
 			const position = ctx.selection.getPosition();
 			return new Range(position.lineNumber, position.column - 1, position.lineNumber, position.column + 1);
 		}
diff --git a/src/vs/editor/common/modes/languageConfiguration.ts b/src/vs/editor/common/modes/languageConfiguration.ts
--- a/src/vs/editor/common/modes/languageConfiguration.ts
+++ b/src/vs/editor/common/modes/languageConfiguration.ts
@@ -294,17 +294,32 @@ export class StandardAutoClosingPairConditional {
  * @internal
  */
 export class AutoClosingPairs {
+	// it is useful to be able to get pairs using either end of open and close
 
-	public readonly autoClosingPairsOpen: Map<string, StandardAutoClosingPairConditional[]>;
-	public readonly autoClosingPairsClose: Map<string, StandardAutoClosingPairConditional[]>;
+	/** Key is first character of open */
+	public readonly autoClosingPairsOpenByStart: Map<string, StandardAutoClosingPairConditional[]>;
+	/** Key is last character of open */
+	public readonly autoClosingPairsOpenByEnd: Map<string, StandardAutoClosingPairConditional[]>;
+	/** Key is first character of close */
+	public readonly autoClosingPairsCloseByStart: Map<string, StandardAutoClosingPairConditional[]>;
+	/** Key is last character of close */
+	public readonly autoClosingPairsCloseByEnd: Map<string, StandardAutoClosingPairConditional[]>;
+	/** Key is close. Only has pairs that are a single character */
+	public readonly autoClosingPairsCloseSingleChar: Map<string, StandardAutoClosingPairConditional[]>;
 
 	constructor(autoClosingPairs: StandardAutoClosingPairConditional[]) {
-		this.autoClosingPairsOpen = new Map<string, StandardAutoClosingPairConditional[]>();
-		this.autoClosingPairsClose = new Map<string, StandardAutoClosingPairConditional[]>();
+		this.autoClosingPairsOpenByStart = new Map<string, StandardAutoClosingPairConditional[]>();
+		this.autoClosingPairsOpenByEnd = new Map<string, StandardAutoClosingPairConditional[]>();
+		this.autoClosingPairsCloseByStart = new Map<string, StandardAutoClosingPairConditional[]>();
+		this.autoClosingPairsCloseByEnd = new Map<string, StandardAutoClosingPairConditional[]>();
+		this.autoClosingPairsCloseSingleChar = new Map<string, StandardAutoClosingPairConditional[]>();
 		for (const pair of autoClosingPairs) {
-			appendEntry(this.autoClosingPairsOpen, pair.open.charAt(pair.open.length - 1), pair);
-			if (pair.close.length === 1) {
-				appendEntry(this.autoClosingPairsClose, pair.close, pair);
+			appendEntry(this.autoClosingPairsOpenByStart, pair.open.charAt(0), pair);
+			appendEntry(this.autoClosingPairsOpenByEnd, pair.open.charAt(pair.open.length - 1), pair);
+			appendEntry(this.autoClosingPairsCloseByStart, pair.close.charAt(0), pair);
+			appendEntry(this.autoClosingPairsCloseByEnd, pair.close.charAt(pair.close.length - 1), pair);
+			if (pair.close.length === 1 && pair.open.length === 1) {
+				appendEntry(this.autoClosingPairsCloseSingleChar, pair.close, pair);
 			}
 		}
 	}


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
@@ -4660,7 +4660,7 @@ suite('autoClosingPairs', () => {
 				'v|ar |c = \'|asd\';|',
 				'v|ar d = "|asd";|',
 				'v|ar e = /*3*/	3;|',
-				'v|ar f = /** 3 */3;|',
+				'v|ar f = /** 3| */3;|',
 				'v|ar g = (3+5|);|',
 				'v|ar h = { |a: \'v|alue\' |};|',
 			];
@@ -4841,13 +4841,13 @@ suite('autoClosingPairs', () => {
 
 			let autoClosePositions = [
 				'var a |=| [|]|;|',
-				'var b |=| |`asd`|;|',
-				'var c |=| |\'asd\'|;|',
-				'var d |=| |"asd"|;|',
+				'var b |=| `asd`|;|',
+				'var c |=| \'asd\'|;|',
+				'var d |=| "asd"|;|',
 				'var e |=| /*3*/|	3;|',
 				'var f |=| /**| 3 */3;|',
 				'var g |=| (3+5)|;|',
-				'var h |=| {| a:| |\'value\'| |}|;|',
+				'var h |=| {| a:| \'value\'| |}|;|',
 			];
 			for (let i = 0, len = autoClosePositions.length; i < len; i++) {
 				const lineNumber = i + 1;
@@ -4890,6 +4890,51 @@ suite('autoClosingPairs', () => {
 		mode.dispose();
 	});
 
+	test('issue #72177: multi-character autoclose with conflicting patterns', () => {
+		const languageId = new LanguageIdentifier('autoClosingModeMultiChar', 5);
+		class AutoClosingModeMultiChar extends MockMode {
+			constructor() {
+				super(languageId);
+				this._register(LanguageConfigurationRegistry.register(this.getLanguageIdentifier(), {
+					autoClosingPairs: [
+						{ open: '(', close: ')' },
+						{ open: '(*', close: '*)' },
+						{ open: '<@', close: '@>' },
+						{ open: '<@@', close: '@@>' },
+					],
+				}));
+			}
+		}
+
+		const mode = new AutoClosingModeMultiChar();
+
+		usingCursor({
+			text: [
+				'',
+			],
+			languageIdentifier: mode.getLanguageIdentifier()
+		}, (editor, model, viewModel) => {
+			viewModel.type('(', 'keyboard');
+			assert.strictEqual(model.getLineContent(1), '()');
+			viewModel.type('*', 'keyboard');
+			assert.strictEqual(model.getLineContent(1), '(**)', `doesn't add entire close when already closed substring is there`);
+
+			model.setValue('(');
+			viewModel.setSelections('test', [new Selection(1, 2, 1, 2)]);
+			viewModel.type('*', 'keyboard');
+			assert.strictEqual(model.getLineContent(1), '(**)', `does add entire close if not already there`);
+
+			model.setValue('');
+			viewModel.type('<@', 'keyboard');
+			assert.strictEqual(model.getLineContent(1), '<@@>');
+			viewModel.type('@', 'keyboard');
+			assert.strictEqual(model.getLineContent(1), '<@@@@>', `autocloses when before multi-character closing brace`);
+			viewModel.type('(', 'keyboard');
+			assert.strictEqual(model.getLineContent(1), '<@@()@@>', `autocloses when before multi-character closing brace`);
+		});
+		mode.dispose();
+	});
+
 	test('issue #55314: Do not auto-close when ending with open', () => {
 		const languageId = new LanguageIdentifier('myElectricMode', 5);
 		class ElectricMode extends MockMode {
@@ -4943,7 +4988,7 @@ suite('autoClosingPairs', () => {
 			],
 			languageIdentifier: mode.getLanguageIdentifier()
 		}, (editor, model, viewModel) => {
-			assertType(editor, model, viewModel, 1, 12, '"', '""', `does not over type and will auto close`);
+			assertType(editor, model, viewModel, 1, 12, '"', '"', `does not over type and will not auto close`);
 		});
 		mode.dispose();
 	});
@@ -5304,7 +5349,7 @@ suite('autoClosingPairs', () => {
 			assert.equal(model.getValue(), 'console.log(\'it\\\');');
 
 			viewModel.type('\'', 'keyboard');
-			assert.equal(model.getValue(), 'console.log(\'it\\\'\'\');');
+			assert.equal(model.getValue(), 'console.log(\'it\\\'\');');
 		});
 		mode.dispose();
 	});