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; }