diff --git a/src/vs/editor/common/controller/cursor.ts b/src/vs/editor/common/controller/cursor.ts index 7a966ed223ec6dfda867be899e012e55c9ed3f0d..2bb67894dc9b060f5f16063a4ba1616e59621748 100644 --- 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 index 92d36e239ad869acd7f2bb4b57ef8350c1e50e7d..aa4a3a89f89527ec853c1d6f510eeb035aa6c946 100644 --- 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; - public readonly autoClosingPairsClose2: Map; + 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 index 1147e78ae1c5c8261bcc279e2855e58598e56911..1a4a86d87952ed47e9fbbc94ca63dde38bb08b14 100644 --- 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] { - 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 index cf538a83386853fd6aff90f2002fadcf3e640c0b..75da04850bb0aa634d17e3d43b6df0f50adf64d3 100644 --- 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 index c67d8f968e4f52ef8ff20fd001e4d0272e54b977..75c25ccc0219cb668fe53e765f66808dd466e2b2 100644 --- 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 index 295fbdc01df110cebaabe1df89102a03640b1f1b..a383c57e970296283c9f656c88ac5a0c469fc922 100644 --- 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; - public readonly autoClosingPairsClose: Map; + /** Key is first character of open */ + public readonly autoClosingPairsOpenByStart: Map; + /** Key is last character of open */ + public readonly autoClosingPairsOpenByEnd: Map; + /** Key is first character of close */ + public readonly autoClosingPairsCloseByStart: Map; + /** Key is last character of close */ + public readonly autoClosingPairsCloseByEnd: Map; + /** Key is close. Only has pairs that are a single character */ + public readonly autoClosingPairsCloseSingleChar: Map; constructor(autoClosingPairs: StandardAutoClosingPairConditional[]) { - this.autoClosingPairsOpen = new Map(); - this.autoClosingPairsClose = new Map(); + this.autoClosingPairsOpenByStart = new Map(); + this.autoClosingPairsOpenByEnd = new Map(); + this.autoClosingPairsCloseByStart = new Map(); + this.autoClosingPairsCloseByEnd = new Map(); + this.autoClosingPairsCloseSingleChar = new Map(); 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); } } } diff --git a/src/vs/editor/test/browser/controller/cursor.test.ts b/src/vs/editor/test/browser/controller/cursor.test.ts index f97dece5224ba3fbd7912a2136ad340850507a6a..ac2fa484062a684facfe3b0c501ba0e702758611 100644 --- 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(); });