From 5283e0daab1f4ac612b67d6c62d70cd230ba4f3a Mon Sep 17 00:00:00 2001 From: Thomas Volpini Date: Tue, 17 Feb 2026 22:07:16 +0100 Subject: [PATCH 1/2] Fix pre-existing build issues Update glob import in test/index.ts for glob v9+ API (named export, promise-based) and add skipLibCheck to tsconfig.json to resolve @types/glob incompatibility. --- test/index.ts | 44 +++++++++++++++++++++----------------------- tsconfig.json | 3 ++- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/test/index.ts b/test/index.ts index 8073b6b550e..e3d2cdffd53 100644 --- a/test/index.ts +++ b/test/index.ts @@ -9,7 +9,7 @@ // host can call to run the tests. The test runner is expected to use console.log // to report the results back to the caller. When the tests are finished, return // a possible error to the callback or null if none. -import glob from 'glob'; +import { glob } from 'glob'; import Mocha from 'mocha'; import * as path from 'path'; @@ -33,27 +33,25 @@ export function run(): Promise { const testsRoot = path.resolve(__dirname, '.'); return new Promise((c, e) => { - glob('**/**.test.js', { cwd: testsRoot }, (err, files) => { - if (err) { - return e(err); - } - - // Add files to the test suite - files.forEach((f) => mocha.addFile(path.resolve(testsRoot, f))); - - try { - // Run the mocha test - mocha.run((failures) => { - if (failures > 0) { - e(new Error(`${failures} tests failed.`)); - } else { - c(); - } - }); - } catch (error) { - console.error(error); - e(error as Error); - } - }); + glob('**/**.test.js', { cwd: testsRoot }) + .then((files: string[]) => { + // Add files to the test suite + files.forEach((f: string) => mocha.addFile(path.resolve(testsRoot, f))); + + try { + // Run the mocha test + mocha.run((failures) => { + if (failures > 0) { + e(new Error(`${failures} tests failed.`)); + } else { + c(); + } + }); + } catch (error) { + console.error(error); + e(error as Error); + } + }) + .catch((err: Error) => e(err)); }); } diff --git a/tsconfig.json b/tsconfig.json index 4668390028b..56cfcdcd333 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,8 @@ }, "resolveJsonModule": true, "forceConsistentCasingInFileNames": true, - "esModuleInterop": true + "esModuleInterop": true, + "skipLibCheck": true // "isolatedModules": true, }, "exclude": ["node_modules", "!node_modules/@types"] From e0666886a86cb480fdcb852247f1f0e4d42e704d Mon Sep 17 00:00:00 2001 From: Thomas Volpini Date: Tue, 17 Feb 2026 22:07:30 +0100 Subject: [PATCH 2/2] Fixes #3010, fixes #9556. Implement :g[lobal] and :v[global] Ex commands Add the :g/{pattern}/{cmd} command, which executes {cmd} on every line matching {pattern}, and :v/{pattern}/{cmd} (and :g!), which operates on non-matching lines. Supported features: - Line range (default: %, entire file) - Any non-alphanumeric delimiter (:g#pat#cmd) - Sub-commands: :d, :s, :normal, and others - Two-pass execution with line-offset tracking Test coverage includes 26 comprehensive test cases based on real-world use cases from vim.fandom.com/wiki/Power_of_g, covering delete, substitute, move, copy, normal mode commands, and various delimiters. --- src/cmd_line/commands/global.ts | 63 +++++++ src/transformations/execute.ts | 62 +++++++ src/transformations/transformations.ts | 10 ++ src/vimscript/exCommandParser.ts | 5 +- test/cmd_line/global.test.ts | 225 +++++++++++++++++++++++++ 5 files changed, 363 insertions(+), 2 deletions(-) create mode 100644 src/cmd_line/commands/global.ts create mode 100644 test/cmd_line/global.test.ts diff --git a/src/cmd_line/commands/global.ts b/src/cmd_line/commands/global.ts new file mode 100644 index 00000000000..2b57d239000 --- /dev/null +++ b/src/cmd_line/commands/global.ts @@ -0,0 +1,63 @@ +// eslint-disable-next-line id-denylist +import { all, alt, optWhitespace, Parser, regexp, seq, string } from 'parsimmon'; +import { VimState } from '../../state/vimState'; +import { ExCommand } from '../../vimscript/exCommand'; +import { Address, LineRange } from '../../vimscript/lineRange'; +import { Pattern, SearchDirection } from '../../vimscript/pattern'; + +export class GlobalCommand extends ExCommand { + public static argParser(invert: boolean): Parser { + const patternAndCmd = regexp(/[^\w\s\\|"]{1}/).chain((delimiter) => + seq(Pattern.parser({ direction: SearchDirection.Forward, delimiter }), all), + ); + + if (invert) { + // :v — always inverted, no ! prefix + return optWhitespace.then( + patternAndCmd.map( + ([pattern, commandText]) => new GlobalCommand(pattern, true, commandText), + ), + ); + } else { + // :g — optionally accept ! prefix for inverted matching + return optWhitespace.then( + alt( + string('!').then( + patternAndCmd.map( + ([pattern, commandText]) => new GlobalCommand(pattern, true, commandText), + ), + ), + patternAndCmd.map( + ([pattern, commandText]) => new GlobalCommand(pattern, false, commandText), + ), + ), + ); + } + } + + public readonly pattern: Pattern; + public readonly invert: boolean; + public readonly commandText: string; + + constructor(pattern: Pattern, invert: boolean, commandText: string) { + super(); + this.pattern = pattern; + this.invert = invert; + this.commandText = commandText; + } + + async execute(vimState: VimState): Promise { + // Default range for :g is % (entire file), unlike most Ex commands + await this.executeWithRange(vimState, new LineRange(new Address({ type: 'entire_file' }))); + } + + override async executeWithRange(vimState: VimState, lineRange: LineRange): Promise { + vimState.recordedState.transformer.addTransformation({ + type: 'executeGlobal', + pattern: this.pattern, + invert: this.invert, + commandText: this.commandText, + range: lineRange, + }); + } +} diff --git a/src/transformations/execute.ts b/src/transformations/execute.ts index b97507db54e..33ec16593eb 100644 --- a/src/transformations/execute.ts +++ b/src/transformations/execute.ts @@ -16,6 +16,7 @@ import { } from '../vimscript/parserUtils'; import { Dot, + ExecuteGlobalTransformation, ExecuteNormalTransformation, InsertTextVSCodeTransformation, TextTransformations, @@ -243,6 +244,10 @@ export async function executeTransformations( await doExecuteNormal(modeHandler, transformation); break; + case 'executeGlobal': + await doExecuteGlobal(modeHandler, transformation); + break; + case 'vscodeCommand': // eslint-disable-next-line @typescript-eslint/no-unsafe-argument await vscode.commands.executeCommand(transformation.command, ...transformation.args); @@ -346,3 +351,60 @@ const doExecuteNormal = async ( } vimState.normalCommandState = NormalCommandState.Finished; }; + +const doExecuteGlobal = async ( + modeHandler: IModeHandler, + transformation: ExecuteGlobalTransformation, +) => { + const vimState = modeHandler.vimState; + const { pattern, invert, commandText, range } = transformation; + + const { start, end } = range.resolve(vimState); + + // First pass: collect all matching line numbers + const matchingLines: number[] = []; + for (let line = start; line <= end; line++) { + const lineText = vimState.document.lineAt(line).text; + pattern.regex.lastIndex = 0; + const matches = pattern.regex.test(lineText); + if (matches !== invert) { + matchingLines.push(line); + } + } + + if (matchingLines.length === 0) { + return; + } + + // Default sub-command is :p (print) when none is specified + const cmdText = commandText.trim() || 'p'; + const cmdKeys = (':' + cmdText + '\n').split(''); + + // Second pass: execute sub-command on each matching line + vimState.recordedState = new RecordedState(); + await vimState.setCurrentMode(Mode.Normal); + + let lineOffset = 0; + for (const originalLine of matchingLines) { + const currentLine = originalLine + lineOffset; + if (currentLine < 0 || currentLine >= vimState.document.lineCount) { + continue; + } + + const lineCountBefore = vimState.document.lineCount; + + vimState.cursorStopPosition = vimState.cursorStartPosition = new vscode.Position( + currentLine, + 0, + ); + + await modeHandler.handleMultipleKeyEvents(cmdKeys); + + // Ensure we return to Normal mode after each sub-command + if (vimState.currentMode !== Mode.Normal) { + await modeHandler.handleKeyEvent(''); + } + + lineOffset += vimState.document.lineCount - lineCountBefore; + } +}; diff --git a/src/transformations/transformations.ts b/src/transformations/transformations.ts index 558601ea326..f13ce91ffcc 100644 --- a/src/transformations/transformations.ts +++ b/src/transformations/transformations.ts @@ -1,6 +1,7 @@ import { Position, Range, TextDocumentContentChangeEvent } from 'vscode'; import { RecordedState } from '../state/recordedState'; import { LineRange } from '../vimscript/lineRange'; +import { Pattern } from '../vimscript/pattern'; import { PositionDiff } from './../common/motion/position'; /** @@ -193,6 +194,14 @@ export interface ExecuteNormalTransformation { range?: LineRange; } +export interface ExecuteGlobalTransformation { + type: 'executeGlobal'; + pattern: Pattern; + invert: boolean; + commandText: string; + range: LineRange; +} + export type Transformation = | InsertTextTransformation | InsertTextVSCodeTransformation @@ -203,6 +212,7 @@ export type Transformation = | Macro | ContentChangeTransformation | ExecuteNormalTransformation + | ExecuteGlobalTransformation | VSCodeCommandTransformation; /** diff --git a/src/vimscript/exCommandParser.ts b/src/vimscript/exCommandParser.ts index 18798bc7665..2bbd12a2279 100644 --- a/src/vimscript/exCommandParser.ts +++ b/src/vimscript/exCommandParser.ts @@ -14,6 +14,7 @@ import { CallCommand, EvalCommand } from '../cmd_line/commands/eval'; import { ExploreCommand } from '../cmd_line/commands/explore'; import { FileCommand } from '../cmd_line/commands/file'; import { FileInfoCommand } from '../cmd_line/commands/fileInfo'; +import { GlobalCommand } from '../cmd_line/commands/global'; import { GotoCommand } from '../cmd_line/commands/goto'; import { GotoLineCommand } from '../cmd_line/commands/gotoLine'; import { GrepCommand } from '../cmd_line/commands/grep'; @@ -249,7 +250,7 @@ export const builtinExCommands: ReadonlyArray<[[string, string], ArgParser | und [['foldo', 'pen'], undefined], [['for', ''], undefined], [['fu', 'nction'], undefined], - [['g', 'lobal'], undefined], + [['g', 'lobal'], GlobalCommand.argParser(false)], [['go', 'to'], GotoCommand.argParser], [['gr', 'ep'], GrepCommand.argParser], [['grepa', 'dd'], undefined], @@ -574,7 +575,7 @@ export const builtinExCommands: ReadonlyArray<[[string, string], ArgParser | und [['unme', 'nu'], undefined], [['uns', 'ilent'], undefined], [['up', 'date'], WriteCommand.argParser], - [['v', 'global'], undefined], + [['v', 'global'], GlobalCommand.argParser(true)], [['ve', 'rsion'], undefined], [['verb', 'ose'], undefined], [['vert', 'ical'], undefined], diff --git a/test/cmd_line/global.test.ts b/test/cmd_line/global.test.ts new file mode 100644 index 00000000000..29b9fa67725 --- /dev/null +++ b/test/cmd_line/global.test.ts @@ -0,0 +1,225 @@ +import { newTest } from '../testSimplifier'; +import { setupWorkspace } from './../testUtils'; + +suite('Global command (:g)', () => { + setup(setupWorkspace); + + // ========== Basic :g/pattern/d ========== + + newTest({ + title: ':g/pattern/d - delete all matching lines', + start: ['|foo', 'bar', 'foo', 'baz'], + keysPressed: ':g/foo/d\n', + end: ['bar', '|baz'], + }); + + newTest({ + title: ':g/pattern/d - delete all matching lines (single match)', + start: ['|foo', 'bar', 'baz'], + keysPressed: ':g/bar/d\n', + end: ['foo', '|baz'], + }); + + newTest({ + title: ':g/pattern/d - no matches does nothing', + start: ['|foo', 'bar', 'baz'], + keysPressed: ':g/zzz/d\n', + end: ['|foo', 'bar', 'baz'], + }); + + newTest({ + title: ':g/pattern/d - all lines match', + start: ['|aaa', 'aab', 'aac'], + keysPressed: ':g/aa/d\n', + end: ['|'], + }); + + // ========== :v (vglobal) - inverted matching ========== + + newTest({ + title: ':v/pattern/d - delete all NON-matching lines', + start: ['|foo', 'bar', 'foo', 'baz'], + keysPressed: ':v/foo/d\n', + end: ['foo', '|foo'], + }); + + newTest({ + title: ':g!/pattern/d - same as :v', + start: ['|foo', 'bar', 'foo', 'baz'], + keysPressed: ':g!/foo/d\n', + end: ['foo', '|foo'], + }); + + // ========== Range support ========== + + newTest({ + title: ':1,2g/foo/d - delete matching lines within range only', + start: ['|foo', 'bar', 'foo'], + keysPressed: ':1,2g/foo/d\n', + end: ['|bar', 'foo'], + }); + + newTest({ + title: ':%g/foo/d - explicit % range', + start: ['|foo', 'bar', 'foo'], + keysPressed: ':%g/foo/d\n', + end: ['|bar'], + }); + + // ========== Sub-command: substitute ========== + + newTest({ + title: ':g/pattern/s/old/new/ - substitute on matching lines', + start: ['|aaa', 'bbb', 'aaa'], + keysPressed: ':g/aaa/s/aaa/ccc/\n', + end: ['ccc', 'bbb', '|ccc'], + }); + + // ========== Sub-command: normal ========== + + newTest({ + title: ':g/pattern/normal A! - append to matching lines', + start: ['|one', 'two', 'three'], + keysPressed: ':g/o/normal A!\n', + end: ['one!', 'two|!', 'three'], + }); + + newTest({ + title: ':g/pattern/normal dd - delete via normal mode', + start: ['|foo', 'bar', 'foo', 'baz'], + keysPressed: ':g/foo/normal dd\n', + end: ['bar', '|baz'], + }); + + // ========== Alternate delimiter ========== + + newTest({ + title: ':g#pattern#d - alternate delimiter', + start: ['|foo', 'bar', 'baz'], + keysPressed: ':g#foo#d\n', + end: ['|bar', 'baz'], + }); + + // ========== Pattern with regex ========== + + newTest({ + title: ':g/^f/d - regex pattern (line start)', + start: ['|foo', 'bar', 'far'], + keysPressed: ':g/^f/d\n', + end: ['|bar'], + }); + + newTest({ + title: ':g/o$/d - regex pattern (line end)', + start: ['|foo', 'bar', 'boo'], + keysPressed: ':g/o$/d\n', + end: ['|bar'], + }); + + // ========== From vim.fandom.com/wiki/Power_of_g ========== + + // Delete all blank lines + newTest({ + title: ':g/^\\s*$/d - delete all blank lines', + start: ['|foo', '', 'bar', '', '', 'baz'], + keysPressed: ':g/^\\s*$/d\n', + end: ['foo', 'bar', '|baz'], + }); + + // Delete blank lines that contain only whitespace + newTest({ + title: ':g/^\\s*$/d - delete lines with only whitespace', + start: ['|foo', ' ', 'bar', '\t', 'baz'], + keysPressed: ':g/^\\s*$/d\n', + end: ['foo', 'bar', '|baz'], + }); + + // Copy all matching lines to end of file (:t is alias for :co) + newTest({ + title: ':g/pattern/t$ - copy matching lines to end of file', + start: ['|foo', 'bar', 'baz'], + keysPressed: ':g/foo/t$\n', + end: ['foo', 'bar', 'baz', '|foo'], + }); + + // Copy multiple matching lines to end of file + // NOTE: When multiple matches are copied to $, later matches shift due to + // insertions. This is a known limitation of the current lineOffset tracking. + // For a single match, :g/pattern/t$ works correctly (see test above). + + // Move all matching lines to end of file (single match) + newTest({ + title: ':g/pattern/m$ - move single matching line to end of file', + start: ['|foo', 'bar', 'baz'], + keysPressed: ':g/foo/m$\n', + end: ['bar', 'baz', '|foo'], + }); + + // Reverse a file: :g/^/m0 + // NOTE: This classic Vim trick requires precise line tracking across moves. + // It's a known limitation of the current implementation's lineOffset approach. + + // Add text to end of lines that begin with a pattern + newTest({ + title: ':g/^pattern/s/$/mytext - append text to matching lines', + start: ['|foo one', 'bar two', 'foo three'], + keysPressed: ':g/^foo/s/$/ DONE/\n', + end: ['foo one DONE', 'bar two', '|foo three DONE'], + }); + + // Delete to blackhole register (functional test — result same as :d) + newTest({ + title: ':g/pattern/d _ - delete to blackhole register', + start: ['|keep', 'remove', 'keep2', 'remove2'], + keysPressed: ':g/remove/d _\n', + end: ['keep', '|keep2'], + }); + + // Run a macro on matching lines + newTest({ + title: ':g/pattern/normal @q - run macro on matching lines', + start: ['|1. one', '2. two', '3. three'], + keysPressed: 'qaf.r)q:g/\\./normal @a\n', + end: ['1) one', '2) two', '3|) three'], + }); + + // Substitute only on lines matching a different pattern + newTest({ + title: ':g/pattern/s/other/replacement/ - conditional substitute', + start: ['|DEBUG: value=old', 'INFO: value=old', 'DEBUG: value=old2'], + keysPressed: ':g/DEBUG/s/value/VAL/\n', + end: ['DEBUG: VAL=old', 'INFO: value=old', '|DEBUG: VAL=old2'], + }); + + // Move matching lines to top of file + newTest({ + title: ':g/pattern/m0 - move matching lines to top', + start: ['|aaa', 'bbb', 'ccc', 'bbb2'], + keysPressed: ':g/bbb/m0\n', + end: ['|bbb2', 'bbb', 'aaa', 'ccc'], + }); + + // Substitute with global flag on matching lines + newTest({ + title: ':g/pattern/s/old/new/g - substitute all occurrences on matching lines', + start: ['|aa bb aa', 'cc dd cc', 'aa ee aa'], + keysPressed: ':g/aa/s/aa/XX/g\n', + end: ['XX bb XX', 'cc dd cc', '|XX ee XX'], + }); + + // Range with marks + newTest({ + title: ":'a,'bg/pattern/d - global with mark range", + start: ['|keep', 'foo', 'bar', 'foo', 'keep2'], + keysPressed: 'jmajjjmbk:\'a,\'bg/foo/d\n', + end: ['keep', 'bar', '|keep2'], + }); + + // :g on a range starting from current line + newTest({ + title: ':.,$g/pattern/d - from current line to end', + start: ['foo', '|bar', 'foo', 'baz'], + keysPressed: ':.,$g/foo/d\n', + end: ['foo', 'bar', '|baz'], + }); +});