From bce7e56bda6f62dbc7e5b025ff6de873ea8f9de8 Mon Sep 17 00:00:00 2001 From: Clara Domingos Date: Tue, 2 Jun 2026 12:11:50 +0100 Subject: [PATCH] Feature: Implement concatenation on Enter for string literals When a developer is editing a string literal and presses Enter in the middle of it, the editor currently has no built in mechanism to automatically split the string into two syntactically valid, concatenated substrings. The implementation consists in concatenating the string automatically when Enter is pressed, using per-language adaptation. Example in C++: Before: std::string s = "hel|lo world"; After: std::string s = "hel" + "lo world"; Closes #83196 Co-authored-by: Vasco Reino --- extensions/cpp/language-configuration.json | 11 +- extensions/csharp/language-configuration.json | 14 +- extensions/go/language-configuration.json | 10 +- extensions/java/language-configuration.json | 12 +- .../javascript-language-configuration.json | 12 +- extensions/lua/language-configuration.json | 7 + extensions/python/language-configuration.json | 11 +- src/vs/editor/common/config/editorOptions.ts | 25 ++- .../common/cursor/cursorTypeEditOperations.ts | 26 +++ src/vs/editor/common/cursorCommon.ts | 6 +- .../common/languages/languageConfiguration.ts | 30 +++ .../languageConfigurationRegistry.ts | 21 ++- .../common/languages/stringConcatenation.ts | 175 ++++++++++++++++++ .../common/standalone/standaloneEnums.ts | 3 +- .../browser/stringConcatenationActions.ts | 47 +++++ src/vs/editor/editor.all.ts | 1 + .../test/browser/controller/cursor.test.ts | 138 ++++++++++++++ src/vs/monaco.d.ts | 26 ++- .../languageConfigurationExtensionPoint.ts | 23 ++- 19 files changed, 585 insertions(+), 13 deletions(-) create mode 100644 src/vs/editor/common/languages/stringConcatenation.ts create mode 100644 src/vs/editor/contrib/stringConcatenation/browser/stringConcatenationActions.ts diff --git a/extensions/cpp/language-configuration.json b/extensions/cpp/language-configuration.json index a4468a758f967..9c00f17975961 100644 --- a/extensions/cpp/language-configuration.json +++ b/extensions/cpp/language-configuration.json @@ -128,5 +128,14 @@ "appendText": "// " } }, - ] + ], + "stringConcatenation":{ + "excludedPatterns": [ + "^\\s*#\\s*include", + "^\\s*#\\s*pragma", + "^\\s*#\\s*error", + "^\\s*#\\s*warning", + "^\\s*#\\s*define", + ] + } } diff --git a/extensions/csharp/language-configuration.json b/extensions/csharp/language-configuration.json index b25bc0acfee4d..8d49b35d8706d 100644 --- a/extensions/csharp/language-configuration.json +++ b/extensions/csharp/language-configuration.json @@ -126,5 +126,17 @@ "appendText": "/// " } }, - ] + ], + "stringConcatenation":{ + "excludedPatterns":[ + "^\\s*using", + "^\\s*//", + "^\\s*/\\*", + "^\\s*#\\s*region", + "^\\s*#\\s*pragma", + "^\\s*\\[", + "^\\s*namespace", + "^\\s*throw\\s+new\\s+\\w+\\(" + ] + } } diff --git a/extensions/go/language-configuration.json b/extensions/go/language-configuration.json index 9238bf3529b04..2a3d1c56c6dd0 100644 --- a/extensions/go/language-configuration.json +++ b/extensions/go/language-configuration.json @@ -106,5 +106,13 @@ "appendText": "// " } }, - ] + ], + "stringConcatenation":{ + "excludedPatterns":[ + "^\\s*import", + "^\\s*package", + "^\\s*//", + "^\\s*/\\*" + ] + } } diff --git a/extensions/java/language-configuration.json b/extensions/java/language-configuration.json index 6ba09bbd15c01..dd61687eb059a 100644 --- a/extensions/java/language-configuration.json +++ b/extensions/java/language-configuration.json @@ -168,5 +168,15 @@ "appendText": "// " } }, - ] + ], + "stringConcatenation":{ + "excludedPatterns":[ + "^\\s*import", + "^\\s*package", + "^\\s*//", + "^\\s*/\\*", + "^\\s*@", + "^\\s*throw\\s+new\\s+\\w+\\(" + ] + } } diff --git a/extensions/javascript/javascript-language-configuration.json b/extensions/javascript/javascript-language-configuration.json index 7ca6762946c3c..5d088425beaed 100644 --- a/extensions/javascript/javascript-language-configuration.json +++ b/extensions/javascript/javascript-language-configuration.json @@ -249,5 +249,15 @@ "appendText": "// " } }, - ] + ], + "stringConcatenation":{ + "excludedPatterns":[ + "^\\s*import", + "^\\s*export", + "^\\s*require", + "^\\s*//", + "^\\s*/\\*", + "^\\s*@" + ] + } } diff --git a/extensions/lua/language-configuration.json b/extensions/lua/language-configuration.json index 5e5e0ab447706..c1ab803125278 100644 --- a/extensions/lua/language-configuration.json +++ b/extensions/lua/language-configuration.json @@ -25,5 +25,12 @@ "indentationRules": { "increaseIndentPattern": "^((?!(\\-\\-)).)*((\\b(else|function|then|do|repeat)\\b((?!\\b(end|until)\\b).)*)|(\\{\\s*))$", "decreaseIndentPattern": "^\\s*((\\b(elseif|else|end|until)\\b)|(\\})|(\\)))" + }, + "stringConcatenation": { + "excludedPatterns": [ + "^\\s*require", + "^\\s*--", + "^\\s*--\\[\\[" + ] } } diff --git a/extensions/python/language-configuration.json b/extensions/python/language-configuration.json index 8e3f541412fae..b00997bba3997 100644 --- a/extensions/python/language-configuration.json +++ b/extensions/python/language-configuration.json @@ -52,5 +52,14 @@ "beforeText": "^\\s*(?:def|class|for|if|elif|else|while|try|with|finally|except|async).*?:\\s*$", "action": { "indent": "indent" } } - ] + ], + "stringConcatenation":{ + "excludedPatterns":[ + "^\\s*import", + "^\\s*from", + "^\\s*#", + "^\\s*@", + "^\\s*raise\\s+\\w+\\(" + ] + } } diff --git a/src/vs/editor/common/config/editorOptions.ts b/src/vs/editor/common/config/editorOptions.ts index bf7964361673f..179620fd5ed6b 100644 --- a/src/vs/editor/common/config/editorOptions.ts +++ b/src/vs/editor/common/config/editorOptions.ts @@ -855,6 +855,14 @@ export interface IEditorOptions { * Controls whether the accessibility hint should be provided to screen reader users when an inline completion is shown. */ inlineCompletionsAccessibilityVerbose?: boolean; + + /** + * Enable/disable automatic string concatenation formatting when pressing Enter inside string literal. + * When enabled, pressing Enter inside a string will automatically close the current string and open + * a new one with the appropriate concatenation operator for the current language. + * Defaults to true. + */ + stringConcatenationOnEnter?: boolean; } /** @@ -5933,7 +5941,8 @@ export const enum EditorOption { effectiveEditContext, scrollOnMiddleClick, effectiveAllowVariableFonts, - doubleClickSelectsBlock + doubleClickSelectsBlock, + stringConcatenationOnEnter } export const EditorOptions = { @@ -6845,7 +6854,19 @@ export const EditorOptions = { wrappingIndent: register(new WrappingIndentOption()), wrappingStrategy: register(new WrappingStrategy()), effectiveEditContextEnabled: register(new EffectiveEditContextEnabled()), - effectiveAllowVariableFonts: register(new EffectiveAllowVariableFonts()) + effectiveAllowVariableFonts: register(new EffectiveAllowVariableFonts()), + + stringConcatenationOnEnter: register(new EditorBooleanOption( + EditorOption.stringConcatenationOnEnter, + 'stringConcatenationOnEnter', + true, + { + markdownDescription: nls.localize( + 'stringConcatenationOnEnter', + 'Controls whether pressing `Enter` inside a string literal automatically splits into two concatenated strings using appropriate operator for the current language.' + ) + } + )), }; type EditorOptionsType = typeof EditorOptions; diff --git a/src/vs/editor/common/cursor/cursorTypeEditOperations.ts b/src/vs/editor/common/cursor/cursorTypeEditOperations.ts index d1290a3e651db..e0edb4435a218 100644 --- a/src/vs/editor/common/cursor/cursorTypeEditOperations.ts +++ b/src/vs/editor/common/cursor/cursorTypeEditOperations.ts @@ -23,6 +23,7 @@ import { EditorAutoClosingStrategy, EditorAutoIndentStrategy } from '../config/e import { createScopedLineTokens } from '../languages/supports.js'; import { getIndentActionForType, getIndentForEnter, getInheritIndentForLine } from '../languages/autoIndent.js'; import { getEnterAction } from '../languages/enterAction.js'; +import { getStringConcatenation } from '../languages/stringConcatenation.js'; import { CompositionOutcome } from './cursorTypeOperations.js'; export class AutoIndentOperation { @@ -532,6 +533,11 @@ export class EnterOperation { } private static _enter(config: CursorConfiguration, model: ITextModel, keepPosition: boolean, range: Range): ICommand { + + const excludedPatterns = config.stringConcatenation?.excludedPatterns ?? []; + + const stringConcatAction = getStringConcatenation(model, range, config.stringConcatenationOnEnter, excludedPatterns,); + if (config.autoIndent === EditorAutoIndentStrategy.None) { return typeCommand(range, '\n', keepPosition); } @@ -541,6 +547,26 @@ export class EnterOperation { return typeCommand(range, '\n' + config.normalizeIndentation(indentation), keepPosition); } const r = getEnterAction(config.autoIndent, model, range, config.languageConfigurationService); + + if (stringConcatAction) { + const lineText = model.getLineContent(range.startLineNumber); + const indentation = strings.getLeadingWhitespace(lineText).substring(0, range.startColumn - 1); + + // Expand range to cover the whole line, replacing it entirely + const fullLineRange = new Range( + range.startLineNumber, + 1, + range.endLineNumber, + model.getLineMaxColumn(range.endLineNumber) + ); + + return typeCommand( + fullLineRange, + stringConcatAction.beforeText + '\n' + config.normalizeIndentation(indentation) + stringConcatAction.afterText, + keepPosition + ); + } + if (r) { if (r.indentAction === IndentAction.None) { // Nothing special diff --git a/src/vs/editor/common/cursorCommon.ts b/src/vs/editor/common/cursorCommon.ts index 4b03cf57d26fc..d3d91752e018e 100644 --- a/src/vs/editor/common/cursorCommon.ts +++ b/src/vs/editor/common/cursorCommon.ts @@ -11,7 +11,7 @@ import { ISelection, Selection } from './core/selection.js'; import { ICommand } from './editorCommon.js'; import { IEditorConfiguration } from './config/editorConfiguration.js'; import { PositionAffinity, TextModelResolvedOptions } from './model.js'; -import { AutoClosingPairs } from './languages/languageConfiguration.js'; +import { AutoClosingPairs, IStringConcatenation } from './languages/languageConfiguration.js'; import { ILanguageConfigurationService } from './languages/languageConfigurationRegistry.js'; import { createScopedLineTokens } from './languages/supports.js'; import { IElectricAction } from './languages/supports/electricCharacter.js'; @@ -74,12 +74,14 @@ export class CursorConfiguration { public readonly autoClosingOvertype: EditorAutoClosingEditStrategy; public readonly autoSurround: EditorAutoSurroundStrategy; public readonly autoIndent: EditorAutoIndentStrategy; + public readonly stringConcatenationOnEnter: boolean; public readonly autoClosingPairs: AutoClosingPairs; public readonly surroundingPairs: CharacterMap; public readonly blockCommentStartToken: string | null; public readonly shouldAutoCloseBefore: { quote: (ch: string) => boolean; bracket: (ch: string) => boolean; comment: (ch: string) => boolean }; public readonly wordSegmenterLocales: string[]; public readonly overtypeOnPaste: boolean; + public readonly stringConcatenation: IStringConcatenation | undefined; private readonly _languageId: string; private _electricChars: { [key: string]: boolean } | null; @@ -142,6 +144,7 @@ export class CursorConfiguration { this.autoClosingOvertype = options.get(EditorOption.autoClosingOvertype); this.autoSurround = options.get(EditorOption.autoSurround); this.autoIndent = options.get(EditorOption.autoIndent); + this.stringConcatenationOnEnter = options.get(EditorOption.stringConcatenationOnEnter); this.wordSegmenterLocales = options.get(EditorOption.wordSegmenterLocales); this.overtypeOnPaste = options.get(EditorOption.overtypeOnPaste); @@ -164,6 +167,7 @@ export class CursorConfiguration { } const commentsConfiguration = this.languageConfigurationService.getLanguageConfiguration(languageId).comments; + this.stringConcatenation = this.languageConfigurationService.getLanguageConfiguration(languageId).stringConcatenation; this.blockCommentStartToken = commentsConfiguration?.blockCommentStartToken ?? null; } diff --git a/src/vs/editor/common/languages/languageConfiguration.ts b/src/vs/editor/common/languages/languageConfiguration.ts index c569efd68c160..95454d1aac6a0 100644 --- a/src/vs/editor/common/languages/languageConfiguration.ts +++ b/src/vs/editor/common/languages/languageConfiguration.ts @@ -37,6 +37,17 @@ export interface CommentRule { blockComment?: CharacterPair | null; } +/** + * Stores the excluded patterns per-language + * for string concatenation logic + */ +export interface IStringConcatenation { + /** + * RegEx patterns where string concatenation should not be applied + */ + excludedPatterns: string[]; +} + /** * The language configuration interface defines the contract between extensions and * various editor features, like automatic bracket insertion, automatic indentation etc. @@ -103,6 +114,11 @@ export interface LanguageConfiguration { __electricCharacterSupport?: { docComment?: IDocComment; }; + + /** + * The language's string concatenation excluded patterns. + */ + stringConcatenation?: IStringConcatenation; } /** @@ -260,6 +276,20 @@ export interface EnterAction { removeText?: number; } +/** + * @internal + */ +export interface StringConcatenationOnEnterAction { + /** + * Previous line's text + */ + beforeText: string; + /** + * Concatenated text that has the correspondent per-language operator + */ + afterText: string; +} + /** * @internal */ diff --git a/src/vs/editor/common/languages/languageConfigurationRegistry.ts b/src/vs/editor/common/languages/languageConfigurationRegistry.ts index 0dffed073d4c2..a31759c06f100 100644 --- a/src/vs/editor/common/languages/languageConfigurationRegistry.ts +++ b/src/vs/editor/common/languages/languageConfigurationRegistry.ts @@ -8,7 +8,7 @@ import { Disposable, IDisposable, markAsSingleton, toDisposable } from '../../.. import * as strings from '../../../base/common/strings.js'; import { ITextModel } from '../model.js'; import { DEFAULT_WORD_REGEXP, ensureValidWordDefinition } from '../core/wordHelper.js'; -import { EnterAction, FoldingRules, IAutoClosingPair, IndentationRule, LanguageConfiguration, AutoClosingPairs, CharacterPair, ExplicitLanguageConfiguration } from './languageConfiguration.js'; +import { EnterAction, FoldingRules, IAutoClosingPair, IndentationRule, LanguageConfiguration, AutoClosingPairs, CharacterPair, ExplicitLanguageConfiguration, IStringConcatenation } from './languageConfiguration.js'; import { CharacterPairSupport } from './supports/characterPair.js'; import { BracketElectricCharacterSupport } from './supports/electricCharacter.js'; import { IndentRulesSupport } from './supports/indentRules.js'; @@ -249,6 +249,7 @@ function combineLanguageConfigurations(configs: LanguageConfiguration[]): Langua folding: undefined, colorizedBracketPairs: undefined, __electricCharacterSupport: undefined, + stringConcatenation: undefined, }; for (const entry of configs) { result = { @@ -263,6 +264,7 @@ function combineLanguageConfigurations(configs: LanguageConfiguration[]): Langua folding: entry.folding || result.folding, colorizedBracketPairs: entry.colorizedBracketPairs || result.colorizedBracketPairs, __electricCharacterSupport: entry.__electricCharacterSupport || result.__electricCharacterSupport, + stringConcatenation: entry.stringConcatenation || result.stringConcatenation, }; } @@ -360,6 +362,7 @@ export class ResolvedLanguageConfiguration { public readonly indentationRules: IndentationRule | undefined; public readonly foldingRules: FoldingRules; public readonly bracketsNew: LanguageBracketsConfiguration; + public readonly stringConcatenation: IStringConcatenation | undefined; constructor( public readonly languageId: string, @@ -391,6 +394,7 @@ export class ResolvedLanguageConfiguration { languageId, this.underlyingConfig ); + this.stringConcatenation = ResolvedLanguageConfiguration._handleStringConcatenation(this.underlyingConfig); } public getWordDefinition(): RegExp { @@ -472,6 +476,21 @@ export class ResolvedLanguageConfiguration { return comments; } + + private static _handleStringConcatenation( + conf: LanguageConfiguration + ): IStringConcatenation | undefined { + if (!conf.stringConcatenation) { + return undefined; + } + const excludedPatterns = conf.stringConcatenation.excludedPatterns; + if (!Array.isArray(excludedPatterns)) { + return undefined; + } + return { + excludedPatterns: excludedPatterns.filter(s => typeof s === 'string'), + }; + } } registerSingleton(ILanguageConfigurationService, LanguageConfigurationService, InstantiationType.Delayed); diff --git a/src/vs/editor/common/languages/stringConcatenation.ts b/src/vs/editor/common/languages/stringConcatenation.ts new file mode 100644 index 0000000000000..9e4678c30eed0 --- /dev/null +++ b/src/vs/editor/common/languages/stringConcatenation.ts @@ -0,0 +1,175 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Range } from '../core/range.js'; +import { ITextModel } from '../model.js'; +import { StringConcatenationOnEnterAction } from './languageConfiguration.js'; +import { StandardTokenType } from '../encodedTokenAttributes.js'; + +const DOUBLE_QUOTE = String.fromCharCode(34); // " +const SINGLE_QUOTE = String.fromCharCode(39); // ' + + + +export function getStringConcatenation( + model: ITextModel, + range: Range, + enabled: boolean, + excludedPatterns: string[], +): StringConcatenationOnEnterAction | null { + + if (!enabled) { + return null; + } + + /* we must not apply the string concatenation feature on the excluded patterns */ + const lineText = model.getLineContent(range.startLineNumber); + + let isExcluded = false; + + for (let i = 0; i < excludedPatterns.length; i++) { + const pattern = new RegExp(excludedPatterns[i]); + + const matches = pattern.test(lineText); + + if (matches) { + isExcluded = true; + break; + } + } + + if (isExcluded) { + return null; + } + + + const beforeEnterText = lineText.substring(0, range.startColumn - 1); + const afterEnterText = lineText.substring(range.endColumn - 1); + const languageId = model.getLanguageIdAtPosition(range.startLineNumber, range.startColumn); + + model.tokenization.forceTokenization(range.startLineNumber); + const lineTokens = model.tokenization.getLineTokens(range.startLineNumber); + const tokenIndex = lineTokens.findTokenIndexAtOffset(range.startColumn - 1); + + if (lineTokens.getStandardTokenType(tokenIndex) !== StandardTokenType.String) { + return null; + } + + const operator = getStringConcatenationOperator(languageId); + if (operator === null) { + return null; + } + + const quote = detectOpeningQuote(lineText, range.startColumn - 1); + + const beforeText = beforeEnterText + quote; + const afterText = operator + quote + afterEnterText; + + return { + beforeText: beforeText, + afterText: afterText + }; +} + +function detectOpeningQuote(lineText: string, cursorOffset: number): string { + for (let i = cursorOffset - 1; i >= 0; i--) { + const ch = lineText[i]; + + if (ch === DOUBLE_QUOTE || ch === SINGLE_QUOTE) { + const isEscaped = i > 0 && lineText[i - 1].charCodeAt(0) === 92; + + if (!isEscaped) { + return ch; + } + } + } + + return DOUBLE_QUOTE; +} + +function getStringConcatenationOperator(languageId: string): string | null { + switch (languageId) { + // '+ ' operator + case 'c': + case 'cpp': + case 'csharp': + case 'dart': + case 'go': + case 'groovy': + case 'java': + case 'javascript': + case 'javascriptreact': + case 'julia': + case 'kotlin': + case 'r': + case 'scala': + case 'swift': + case 'typescript': + case 'typescriptreact': + return '+ '; + + // '. ' operator + case 'perl': + case 'perl6': + case 'php': + return '. '; + + // ' ..' operator + case 'lua': + return '.. '; + + // ' <>' operator + case 'elixir': + return '<> '; + + // ' ++' operator + case 'erlang': + case 'haskell': + return '++ '; + + // ' ~' operator + case 'd': + return '~ '; + + // '& ' operator + case 'vb': // Visual Basic .NET + return '& '; + + // '|| ' operator + // SQL dialects that support standard || concat + case 'sql': + return '|| '; + + // implicit / adjacency (empty operator, just newline) + // These languages concat string literals by placing them next to + // each other — no operator needed between literals + case 'python': + case 'coffeescript': + return ''; + + + case 'rust': + + case 'ruby': + // Ruby has + and <<, but splitting a literal mid-line is not idiomatic; + // the backslash continuation is fragile. Return null to skip. + case 'shellscript': + case 'powershell': + case 'fsharp': + case 'ocaml': + case 'markdown': + case 'html': + case 'css': + case 'scss': + case 'less': + case 'json': + case 'jsonc': + case 'xml': + case 'yaml': + + default: + return null; + } +} diff --git a/src/vs/editor/common/standalone/standaloneEnums.ts b/src/vs/editor/common/standalone/standaloneEnums.ts index cb0db8b268d0e..65615e2250c0c 100644 --- a/src/vs/editor/common/standalone/standaloneEnums.ts +++ b/src/vs/editor/common/standalone/standaloneEnums.ts @@ -347,7 +347,8 @@ export enum EditorOption { effectiveEditContext = 170, scrollOnMiddleClick = 171, effectiveAllowVariableFonts = 172, - doubleClickSelectsBlock = 173 + doubleClickSelectsBlock = 173, + stringConcatenationOnEnter = 174 } /** diff --git a/src/vs/editor/contrib/stringConcatenation/browser/stringConcatenationActions.ts b/src/vs/editor/contrib/stringConcatenation/browser/stringConcatenationActions.ts new file mode 100644 index 0000000000000..685693db1afef --- /dev/null +++ b/src/vs/editor/contrib/stringConcatenation/browser/stringConcatenationActions.ts @@ -0,0 +1,47 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import * as nls from '../../../../nls.js'; + +/** + * Command that toggles the string concatenation on Enter feature. + * Accessible via the Command Palette as "Toggle String Concatenation on Enter". + */ +export class ToggleStringConcatenationOnEnterAction extends Action2 { + + static readonly ID = 'editor.action.toggleStringConcatenationOnEnter'; + + constructor() { + super({ + id: ToggleStringConcatenationOnEnterAction.ID, + title: nls.localize2( + 'toggleStringConcatenationOnEnter', + 'Toggle String Concatenation on Enter' + ), + f1: true, + }); + } + + async run(accessor: ServicesAccessor): Promise { + const configurationService = accessor.get(IConfigurationService); + + const currentValue = configurationService.getValue( + 'editor.stringConcatenationOnEnter' + ); + + await configurationService.updateValue( + 'editor.stringConcatenationOnEnter', + !currentValue + ); + } + +} + +registerAction2(ToggleStringConcatenationOnEnterAction); + + diff --git a/src/vs/editor/editor.all.ts b/src/vs/editor/editor.all.ts index 4802aeb185a03..956843a05fe07 100644 --- a/src/vs/editor/editor.all.ts +++ b/src/vs/editor/editor.all.ts @@ -65,6 +65,7 @@ import './contrib/wordPartOperations/browser/wordPartOperations.js'; import './contrib/readOnlyMessage/browser/contribution.js'; import './contrib/diffEditorBreadcrumbs/browser/contribution.js'; import './contrib/floatingMenu/browser/floatingMenu.contribution.js'; +import './contrib/stringConcatenation/browser/stringConcatenationActions.js'; // Load up these strings even in VSCode, even if they are not used // in order to get them translated diff --git a/src/vs/editor/test/browser/controller/cursor.test.ts b/src/vs/editor/test/browser/controller/cursor.test.ts index 61ddc81e06d4b..8a935efe51307 100644 --- a/src/vs/editor/test/browser/controller/cursor.test.ts +++ b/src/vs/editor/test/browser/controller/cursor.test.ts @@ -1589,6 +1589,88 @@ suite('Editor Controller', () => { })); } + function setupStringTokenizationForLanguage(languageId: string): void { + class BaseState implements IState { + constructor(public readonly parent: State | null = null) { } + clone(): IState { return this; } + equals(other: IState): boolean { + if (!(other instanceof BaseState)) { return false; } + if (!this.parent && !other.parent) { return true; } + if (!this.parent || !other.parent) { return false; } + return this.parent.equals(other.parent); + } + } + class StringState implements IState { + constructor(public readonly char: string, public readonly parentState: State) { } + clone(): IState { return this; } + equals(other: IState): boolean { return other instanceof StringState && this.char === other.char && this.parentState.equals(other.parentState); } + } + class BlockCommentState implements IState { + constructor(public readonly parentState: State) { } + clone(): IState { return this; } + equals(other: IState): boolean { return other instanceof StringState && this.parentState.equals(other.parentState); } + } + type State = BaseState | StringState | BlockCommentState; + + const encodedLanguageId = languageService.languageIdCodec.encodeLanguageId(languageId); + disposables.add(TokenizationRegistry.register(languageId, { + getInitialState: () => new BaseState(), + tokenize: undefined!, + tokenizeEncoded: function (line: string, hasEOL: boolean, _state: IState): EncodedTokenizationResult { + let state = _state; + const tokens: { length: number; type: StandardTokenType }[] = []; + const generateToken = (length: number, type: StandardTokenType, newState?: State) => { + if (tokens.length > 0 && tokens[tokens.length - 1].type === type) { + tokens[tokens.length - 1].length += length; + } else { + tokens.push({ length, type }); + } + line = line.substring(length); + if (newState) { state = newState; } + }; + while (line.length > 0) { advance(); } + const result = new Uint32Array(tokens.length * 2); + let startIndex = 0; + for (let i = 0; i < tokens.length; i++) { + result[2 * i] = startIndex; + result[2 * i + 1] = ( + (encodedLanguageId << MetadataConsts.LANGUAGEID_OFFSET) + | (tokens[i].type << MetadataConsts.TOKEN_TYPE_OFFSET) + ); + startIndex += tokens[i].length; + } + return new EncodedTokenizationResult(result, [], state); + + function advance(): void { + if (state instanceof BaseState) { + const m1 = line.match(/^[^'"`{}/]+/g); + if (m1) { return generateToken(m1[0].length, StandardTokenType.Other); } + if (/^['"`]/.test(line)) { return generateToken(1, StandardTokenType.String, new StringState(line.charAt(0), state)); } + if (/^{/.test(line)) { return generateToken(1, StandardTokenType.Other, new BaseState(state)); } + if (/^}/.test(line)) { return generateToken(1, StandardTokenType.Other, state.parent || new BaseState()); } + if (/^\/\//.test(line)) { return generateToken(line.length, StandardTokenType.Comment, state); } + if (/^\/\*/.test(line)) { return generateToken(2, StandardTokenType.Comment, new BlockCommentState(state)); } + return generateToken(1, StandardTokenType.Other, state); + } else if (state instanceof StringState) { + const m1 = line.match(/^[^\\'"`\$]+/g); + if (m1) { return generateToken(m1[0].length, StandardTokenType.String); } + if (/^\\/.test(line)) { return generateToken(2, StandardTokenType.String); } + if (line.charAt(0) === state.char) { return generateToken(1, StandardTokenType.String, state.parentState); } + if (/^\$\{/.test(line)) { return generateToken(2, StandardTokenType.Other, new BaseState(state)); } + return generateToken(1, StandardTokenType.Other, state); + } else if (state instanceof BlockCommentState) { + const m1 = line.match(/^[^*]+/g); + if (m1) { return generateToken(m1[0].length, StandardTokenType.String); } + if (/^\*\//.test(line)) { return generateToken(2, StandardTokenType.Comment, state.parentState); } + return generateToken(1, StandardTokenType.Other, state); + } else { + throw new Error(`unknown state`); + } + } + } + })); + } + function setAutoClosingLanguageEnabledSet(chars: string): void { disposables.add(languageConfigurationService.register(autoClosingLanguageId, { autoCloseBefore: chars, @@ -4331,6 +4413,62 @@ suite('Editor Controller', () => { }); }); + test('String Concatenation in C++ on Enter', () => { + disposables.add(languageService.registerLanguage({ id: 'cpp' })); + setupStringTokenizationForLanguage('cpp'); + disposables.add(languageConfigurationService.register('cpp', { + stringConcatenation: { excludedPatterns: [] } + })); + usingCursor({ + text: ['std::string s = "hello world";'], + languageId: 'cpp', + editorOpts: { stringConcatenationOnEnter: true, autoIndent: 'full' } + }, (editor, model, viewModel) => { + model.tokenization.forceTokenization(1); + moveTo(editor, viewModel, 1, 21, false); + viewModel.type('\n', 'keyboard'); + assert.strictEqual(model.getValue(), 'std::string s = "hel"\n+ "lo world";'); + }); + }); + + test('String Concatenation in Python on Enter', () => { + disposables.add(languageService.registerLanguage({ id: 'python' })); + setupStringTokenizationForLanguage('python'); + disposables.add(languageConfigurationService.register('python', { + stringConcatenation: { excludedPatterns: [] } + })); + usingCursor({ + text: ['message = "hello world"'], + languageId: 'python', + editorOpts: { stringConcatenationOnEnter: true, autoIndent: 'full' } + }, (editor, model, viewModel) => { + model.tokenization.forceTokenization(1); + moveTo(editor, viewModel, 1, 16, false); + viewModel.type('\n', 'keyboard'); + assert.strictEqual(model.getValue(), 'message = "hell"\n"o world"'); + }); + }); + + test('String concatenation should not be applied on #include', () => { + disposables.add(languageService.registerLanguage({ id: 'c' })); + setupStringTokenizationForLanguage('c'); + disposables.add(languageConfigurationService.register('c', { + stringConcatenation: { excludedPatterns: ['^\\s*#\\s*include'] } + })); + + usingCursor({ + text: ['#include '], + languageId: 'c', + editorOpts: { stringConcatenationOnEnter: true, autoIndent: 'full' } + }, (editor, model, viewModel) => { + model.tokenization.forceTokenization(1); + moveTo(editor, viewModel, 1, 13, false); + viewModel.type('\n', 'keyboard'); + // concatenation not applied, normal Enter behaviour + assert.strictEqual(model.getValue(), '#include '); + }); + }); + test('bug #16543: Tab should indent to correct indentation spot immediately', () => { const model = createTextModel( [ diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index fc9c2da70f5d2..dba6af3751c5e 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -4000,6 +4000,13 @@ declare namespace monaco.editor { * Controls whether the accessibility hint should be provided to screen reader users when an inline completion is shown. */ inlineCompletionsAccessibilityVerbose?: boolean; + /** + * Enable/disable automatic string concatenation formatting when pressing Enter inside string literal. + * When enabled, pressing Enter inside a string will automatically close the current string and open + * a new one with the appropriate concatenation operator for the current language. + * Defaults to true. + */ + stringConcatenationOnEnter?: boolean; } export interface IDiffEditorBaseOptions { @@ -5253,7 +5260,8 @@ declare namespace monaco.editor { effectiveEditContext = 170, scrollOnMiddleClick = 171, effectiveAllowVariableFonts = 172, - doubleClickSelectsBlock = 173 + doubleClickSelectsBlock = 173, + stringConcatenationOnEnter = 174 } export const EditorOptions: { @@ -5431,6 +5439,7 @@ declare namespace monaco.editor { wrappingStrategy: IEditorOption; effectiveEditContextEnabled: IEditorOption; effectiveAllowVariableFonts: IEditorOption; + stringConcatenationOnEnter: IEditorOption; }; type EditorOptionsType = typeof EditorOptions; @@ -6970,6 +6979,17 @@ declare namespace monaco.languages { blockComment?: CharacterPair | null; } + /** + * Stores the excluded patterns per-language + * for string concatenation logic + */ + export interface IStringConcatenation { + /** + * RegEx patterns where string concatenation should not be applied + */ + excludedPatterns: string[]; + } + /** * The language configuration interface defines the contract between extensions and * various editor features, like automatic bracket insertion, automatic indentation etc. @@ -7034,6 +7054,10 @@ declare namespace monaco.languages { __electricCharacterSupport?: { docComment?: IDocComment; }; + /** + * The language's string concatenation excluded patterns. + */ + stringConcatenation?: IStringConcatenation; } /** diff --git a/src/vs/workbench/contrib/codeEditor/common/languageConfigurationExtensionPoint.ts b/src/vs/workbench/contrib/codeEditor/common/languageConfigurationExtensionPoint.ts index 9b12b00eb96b8..e53afe0871ba1 100644 --- a/src/vs/workbench/contrib/codeEditor/common/languageConfigurationExtensionPoint.ts +++ b/src/vs/workbench/contrib/codeEditor/common/languageConfigurationExtensionPoint.ts @@ -8,7 +8,7 @@ import { ParseError, parse, getNodeType } from '../../../../base/common/json.js' import { IJSONSchema } from '../../../../base/common/jsonSchema.js'; import * as types from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; -import { CharacterPair, CommentRule, EnterAction, ExplicitLanguageConfiguration, FoldingMarkers, FoldingRules, IAutoClosingPair, IAutoClosingPairConditional, IndentAction, IndentationRule, OnEnterRule } from '../../../../editor/common/languages/languageConfiguration.js'; +import { CharacterPair, CommentRule, EnterAction, ExplicitLanguageConfiguration, FoldingMarkers, FoldingRules, IAutoClosingPair, IAutoClosingPairConditional, IndentAction, IndentationRule, IStringConcatenation, OnEnterRule } from '../../../../editor/common/languages/languageConfiguration.js'; import { ILanguageConfigurationService } from '../../../../editor/common/languages/languageConfigurationRegistry.js'; import { ILanguageService } from '../../../../editor/common/languages/language.js'; import { Extensions, IJSONContributionRegistry } from '../../../../platform/jsonschemas/common/jsonContributionRegistry.js'; @@ -64,6 +64,7 @@ export interface ILanguageConfiguration { }; autoCloseBefore?: string; onEnterRules?: IOnEnterRule[]; + stringConcatenation?: IStringConcatenation; } function isStringArr(something: string[] | null): something is string[] { @@ -396,6 +397,24 @@ export class LanguageConfigurationFileHandler extends Disposable { return result; } + private static _extractValidStringConcatenation(languageId: string, configuration: ILanguageConfiguration): IStringConcatenation | undefined { + const source = configuration.stringConcatenation; + if (typeof source === 'undefined') { + return undefined; + } + if (!types.isObject(source)) { + console.warn(`[${languageId}]: language configuration: expected \`stringConcatenation\` to be an object.`); + return undefined; + } + if (!Array.isArray(source.excludedPatterns)) { + console.warn(`[${languageId}]: language configuration: expected \`stringConcatenation.excludedPatterns\` to be an array.`); + return undefined; + } + return { + excludedPatterns: source.excludedPatterns.filter((s: unknown) => typeof s === 'string'), + }; + } + public static extractValidConfig(languageId: string, configuration: ILanguageConfiguration): ExplicitLanguageConfiguration { const comments = this._extractValidCommentRule(languageId, configuration); @@ -418,6 +437,7 @@ export class LanguageConfigurationFileHandler extends Disposable { }; } const onEnterRules = this._extractValidOnEnterRules(languageId, configuration); + const stringConcatenation = this._extractValidStringConcatenation(languageId, configuration); const richEditConfig: ExplicitLanguageConfiguration = { comments, @@ -431,6 +451,7 @@ export class LanguageConfigurationFileHandler extends Disposable { autoCloseBefore, folding, __electricCharacterSupport: undefined, + stringConcatenation }; return richEditConfig; }