From 3b1a6a1c92bf46f0bede7424809834f1ce982d6c Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Sat, 21 Feb 2026 16:09:47 +0100 Subject: [PATCH 01/12] Working on improving surround in paired performance --- .../src/languages/LanguageDefinition.ts | 12 +++++-- .../TreeSitterQuery/TreeSitterQuery.ts | 33 +++++++++++++++++-- .../getDelimiterOccurrences.ts | 7 +++- 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/packages/cursorless-engine/src/languages/LanguageDefinition.ts b/packages/cursorless-engine/src/languages/LanguageDefinition.ts index 194c0778fb..c926f821c5 100644 --- a/packages/cursorless-engine/src/languages/LanguageDefinition.ts +++ b/packages/cursorless-engine/src/languages/LanguageDefinition.ts @@ -87,11 +87,17 @@ export class LanguageDefinition { * document. We use this in our surrounding pair code. * * @param document The document to search - * @param captureName The name of a capture to search for + * @param captureNames Optional capture names to include * @returns A map of captures in the document */ - getCapturesMap(document: TextDocument) { - const matches = this.query.matches(document); + getCapturesMap( + document: TextDocument, + captureNames?: readonly SimpleScopeTypeType[], + ) { + const matches = + captureNames == null + ? this.query.matches(document) + : this.query.matchesForCaptures(document, new Set(captureNames)); const result: Partial> = {}; for (const match of matches) { diff --git a/packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQuery.ts b/packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQuery.ts index 18f4974bb9..483b5c2db2 100644 --- a/packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQuery.ts +++ b/packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQuery.ts @@ -71,6 +71,13 @@ export class TreeSitterQuery { return treeSitterQueryCache.get(); } + matchesForCaptures( + document: TextDocument, + captureNames: Set, + ): QueryMatch[] { + return this.getAllMatches(document, undefined, undefined, captureNames); + } + private getAllMatches( document: TextDocument, start?: Position, @@ -80,13 +87,23 @@ export class TreeSitterQuery { const results: QueryMatch[] = []; for (const match of matches) { + if ( + captureNameFilter != null && + !match.captures.some((capture) => + captureNameFilter.has(normalizeCaptureName(capture.name)), + ) + ) { + continue; + } const mutableMatch = this.createMutableQueryMatch(document, match); if (!this.runPredicates(mutableMatch)) { continue; } - - results.push(this.createQueryMatch(mutableMatch)); + const queryMatch = this.createQueryMatch(mutableMatch, captureNameFilter); + if (queryMatch != null) { + results.push(queryMatch); + } } return results; @@ -131,7 +148,10 @@ export class TreeSitterQuery { return true; } - private createQueryMatch(match: MutableQueryMatch): QueryMatch { + private createQueryMatch( + match: MutableQueryMatch, + captureNameFilter?: Set, + ): QueryMatch | undefined { const result: MutableQueryCapture[] = []; const map = new Map< string, @@ -145,6 +165,9 @@ export class TreeSitterQuery { for (const capture of match.captures) { const name = normalizeCaptureName(capture.name); + if (captureNameFilter != null && !captureNameFilter.has(name)) { + continue; + } const range = getStartOfEndOfRange(capture); const existing = map.get(name); @@ -168,6 +191,10 @@ export class TreeSitterQuery { } } + if (result.length === 0) { + return undefined; + } + if (this.shouldCheckCaptures) { this.checkCaptures(Array.from(map.values())); } diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/getDelimiterOccurrences.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/getDelimiterOccurrences.ts index 1ec74624e6..2af9777db9 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/getDelimiterOccurrences.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/getDelimiterOccurrences.ts @@ -23,7 +23,12 @@ export function getDelimiterOccurrences( return []; } - const capturesMap = languageDefinition?.getCapturesMap(document) ?? {}; + const capturesMap = + languageDefinition?.getCapturesMap(document, [ + "disqualifyDelimiter", + "pairDelimiter", + "textFragment", + ]) ?? {}; const disqualifyDelimiters = new OneWayRangeFinder( getSortedCaptures(capturesMap.disqualifyDelimiter), ); From 2d2081d6eeeda4ec68920d03669008b37ea94134 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Sat, 21 Feb 2026 16:11:02 +0100 Subject: [PATCH 02/12] fix --- .../src/languages/TreeSitterQuery/TreeSitterQuery.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQuery.ts b/packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQuery.ts index 483b5c2db2..6dcbdaaafe 100644 --- a/packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQuery.ts +++ b/packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQuery.ts @@ -82,6 +82,7 @@ export class TreeSitterQuery { document: TextDocument, start?: Position, end?: Position, + captureNameFilter?: Set, ): QueryMatch[] { const matches = this.getTreeMatches(document, start, end); const results: QueryMatch[] = []; From d05fc45691cfd038d5715861ac2aefa2a5b52096 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Sat, 21 Feb 2026 17:10:19 +0100 Subject: [PATCH 03/12] Added maps of normalized capture names --- .../TreeSitterQuery/TreeSitterQuery.ts | 8 +- .../languages/TreeSitterQuery/captureNames.ts | 94 +++++++++++++++++++ .../TreeSitterQuery/normalizeCaptureName.ts | 3 - .../TreeSitterQuery/validateQueryCaptures.ts | 76 +-------------- 4 files changed, 101 insertions(+), 80 deletions(-) create mode 100644 packages/cursorless-engine/src/languages/TreeSitterQuery/captureNames.ts delete mode 100644 packages/cursorless-engine/src/languages/TreeSitterQuery/normalizeCaptureName.ts diff --git a/packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQuery.ts b/packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQuery.ts index 6dcbdaaafe..8dcf23aa3f 100644 --- a/packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQuery.ts +++ b/packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQuery.ts @@ -1,10 +1,10 @@ import type { Position, TextDocument, TreeSitter } from "@cursorless/common"; import type * as treeSitter from "web-tree-sitter"; import { ide } from "../../singletons/ide.singleton"; +import { getNormalizedCaptureName } from "./captureNames"; import { checkCaptureStartEnd } from "./checkCaptureStartEnd"; import { getNodeRange } from "./getNodeRange"; import { isContainedInErrorNode } from "./isContainedInErrorNode"; -import { normalizeCaptureName } from "./normalizeCaptureName"; import { parsePredicatesWithErrorHandling } from "./parsePredicatesWithErrorHandling"; import { positionToPoint } from "./positionToPoint"; import type { @@ -55,7 +55,7 @@ export class TreeSitterQuery { hasCapture(name: string): boolean { return this.query.captureNames.some( - (n) => normalizeCaptureName(n) === name, + (n) => getNormalizedCaptureName(n) === name, ); } @@ -91,7 +91,7 @@ export class TreeSitterQuery { if ( captureNameFilter != null && !match.captures.some((capture) => - captureNameFilter.has(normalizeCaptureName(capture.name)), + captureNameFilter.has(getNormalizedCaptureName(capture.name)), ) ) { continue; @@ -165,7 +165,7 @@ export class TreeSitterQuery { // name, for which we'd return a capture with name `foo`. for (const capture of match.captures) { - const name = normalizeCaptureName(capture.name); + const name = getNormalizedCaptureName(capture.name); if (captureNameFilter != null && !captureNameFilter.has(name)) { continue; } diff --git a/packages/cursorless-engine/src/languages/TreeSitterQuery/captureNames.ts b/packages/cursorless-engine/src/languages/TreeSitterQuery/captureNames.ts new file mode 100644 index 0000000000..f012a9f9d3 --- /dev/null +++ b/packages/cursorless-engine/src/languages/TreeSitterQuery/captureNames.ts @@ -0,0 +1,94 @@ +import { pseudoScopes, simpleScopeTypeTypes } from "@cursorless/common"; + +const wildcard = "_"; +const captureNames = [ + ...simpleScopeTypeTypes.filter((s) => !pseudoScopes.has(s)), + wildcard, + "interior", +]; + +const positionRelationships = ["prefix", "leading", "trailing"]; +const positionSuffixes = [ + "startOf", + "endOf", + "start.startOf", + "start.endOf", + "end.startOf", + "end.endOf", +]; + +const rangeRelationships = [ + "domain", + "removal", + "iteration", + "iteration.domain", +]; +const rangeSuffixes = [ + "start", + "end", + "start.startOf", + "start.endOf", + "end.startOf", + "end.endOf", +]; + +const allowedCaptures = new Set(); + +for (const captureName of captureNames) { + // Wildcard is not allowed by itself without a relationship + if (captureName !== wildcard) { + // eg: statement + allowedCaptures.add(captureName); + + // eg: statement.start | statement.start.endOf + for (const suffix of rangeSuffixes) { + allowedCaptures.add(`${captureName}.${suffix}`); + } + } + + for (const relationship of positionRelationships) { + // eg: statement.leading + allowedCaptures.add(`${captureName}.${relationship}`); + + for (const suffix of positionSuffixes) { + // eg: statement.leading.endOf + allowedCaptures.add(`${captureName}.${relationship}.${suffix}`); + } + } + + for (const relationship of rangeRelationships) { + // eg: statement.domain + allowedCaptures.add(`${captureName}.${relationship}`); + + for (const suffix of rangeSuffixes) { + // eg: statement.domain.start | statement.domain.start.endOf + allowedCaptures.add(`${captureName}.${relationship}.${suffix}`); + } + } +} + +const normalizedCaptureNamesMap = new Map(); + +for (const captureName of allowedCaptures) { + normalizedCaptureNamesMap.set(captureName, normalizeCaptureName(captureName)); +} + +function normalizeCaptureName(name: string): string { + return name.replace(/(\.(start|end))?(\.(startOf|endOf))?$/, ""); +} + +export function isCaptureAllowed(captureName: string): boolean { + return allowedCaptures.has(captureName); +} + +export function getNormalizedCaptureName(captureName: string): string { + const normalizedCaptureName = normalizedCaptureNamesMap.get(captureName); + + if (normalizedCaptureName == null) { + throw new Error( + `No normalized name for unknown capture name: ${captureName}`, + ); + } + + return normalizedCaptureName; +} diff --git a/packages/cursorless-engine/src/languages/TreeSitterQuery/normalizeCaptureName.ts b/packages/cursorless-engine/src/languages/TreeSitterQuery/normalizeCaptureName.ts deleted file mode 100644 index 5322ff1556..0000000000 --- a/packages/cursorless-engine/src/languages/TreeSitterQuery/normalizeCaptureName.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function normalizeCaptureName(name: string): string { - return name.replace(/(\.(start|end))?(\.(startOf|endOf))?$/, ""); -} diff --git a/packages/cursorless-engine/src/languages/TreeSitterQuery/validateQueryCaptures.ts b/packages/cursorless-engine/src/languages/TreeSitterQuery/validateQueryCaptures.ts index 9ff3f94f19..46b86337b1 100644 --- a/packages/cursorless-engine/src/languages/TreeSitterQuery/validateQueryCaptures.ts +++ b/packages/cursorless-engine/src/languages/TreeSitterQuery/validateQueryCaptures.ts @@ -1,76 +1,6 @@ -import { - pseudoScopes, - showError, - simpleScopeTypeTypes, -} from "@cursorless/common"; +import { showError } from "@cursorless/common"; import { ide } from "../../singletons/ide.singleton"; - -const wildcard = "_"; -const captureNames = [ - ...simpleScopeTypeTypes.filter((s) => !pseudoScopes.has(s)), - wildcard, - "interior", -]; - -const positionRelationships = ["prefix", "leading", "trailing"]; -const positionSuffixes = [ - "startOf", - "endOf", - "start.startOf", - "start.endOf", - "end.startOf", - "end.endOf", -]; - -const rangeRelationships = [ - "domain", - "removal", - "iteration", - "iteration.domain", -]; -const rangeSuffixes = [ - "start", - "end", - "start.startOf", - "start.endOf", - "end.startOf", - "end.endOf", -]; - -const allowedCaptures = new Set(); - -for (const captureName of captureNames) { - // Wildcard is not allowed by itself without a relationship - if (captureName !== wildcard) { - // eg: statement - allowedCaptures.add(captureName); - - // eg: statement.start | statement.start.endOf - for (const suffix of rangeSuffixes) { - allowedCaptures.add(`${captureName}.${suffix}`); - } - } - - for (const relationship of positionRelationships) { - // eg: statement.leading - allowedCaptures.add(`${captureName}.${relationship}`); - - for (const suffix of positionSuffixes) { - // eg: statement.leading.endOf - allowedCaptures.add(`${captureName}.${relationship}.${suffix}`); - } - } - - for (const relationship of rangeRelationships) { - // eg: statement.domain - allowedCaptures.add(`${captureName}.${relationship}`); - - for (const suffix of rangeSuffixes) { - // eg: statement.domain.start | statement.domain.start.endOf - allowedCaptures.add(`${captureName}.${relationship}.${suffix}`); - } - } -} +import { isCaptureAllowed } from "./captureNames"; // Not a comment. ie line is not starting with `;;` // Not a string. @@ -94,7 +24,7 @@ export function validateQueryCaptures(file: string, rawQuery: string): void { continue; } - if (!allowedCaptures.has(captureName)) { + if (!isCaptureAllowed(captureName)) { const lineNumber = match.input.slice(0, match.index).split("\n").length; errors.push(`${file}(${lineNumber}) invalid capture '@${captureName}'.`); } From 418c02d19f7e7f4a835530cbd7852c5e86d5130a Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Sat, 21 Feb 2026 18:13:45 +0100 Subject: [PATCH 04/12] Clean up type --- .../common/src/types/command/PartialTargetDescriptor.types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/common/src/types/command/PartialTargetDescriptor.types.ts b/packages/common/src/types/command/PartialTargetDescriptor.types.ts index 61bbd268da..13a9239a9d 100644 --- a/packages/common/src/types/command/PartialTargetDescriptor.types.ts +++ b/packages/common/src/types/command/PartialTargetDescriptor.types.ts @@ -230,7 +230,7 @@ export interface SimpleScopeType { type: SimpleScopeTypeType; } -export type ScopeTypeType = SimpleScopeTypeType | ScopeType["type"]; +export type ScopeTypeType = ScopeType["type"]; export interface CustomRegexScopeType { type: "customRegex"; From 722355c4ecd44d16432edd1eaa05633accf890a9 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Sat, 21 Feb 2026 18:54:12 +0100 Subject: [PATCH 05/12] Check pattern index --- .../TreeSitterQuery/TreeSitterQuery.ts | 65 +++++++++++++++---- 1 file changed, 53 insertions(+), 12 deletions(-) diff --git a/packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQuery.ts b/packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQuery.ts index 8dcf23aa3f..e3527438d5 100644 --- a/packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQuery.ts +++ b/packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQuery.ts @@ -86,22 +86,49 @@ export class TreeSitterQuery { ): QueryMatch[] { const matches = this.getTreeMatches(document, start, end); const results: QueryMatch[] = []; + const patternContainsFilteredCapture = new Map(); for (const match of matches) { - if ( - captureNameFilter != null && - !match.captures.some((capture) => - captureNameFilter.has(getNormalizedCaptureName(capture.name)), - ) - ) { - continue; + if (captureNameFilter != null) { + let hasFilteredCapture = patternContainsFilteredCapture.get( + match.patternIndex, + ); + + if (hasFilteredCapture == null) { + hasFilteredCapture = match.captures.some((capture) => + captureNameFilter.has(getNormalizedCaptureName(capture.name)), + ); + patternContainsFilteredCapture.set( + match.patternIndex, + hasFilteredCapture, + ); + } + + if (!hasFilteredCapture) { + continue; + } } - const mutableMatch = this.createMutableQueryMatch(document, match); + + const hasPatternPredicates = + this.patternPredicates[match.patternIndex].length > 0; + + const mutableMatch = this.createMutableQueryMatch( + document, + match, + // If there are pattern predicates, we need to include all captures when + // creating the mutable match, since the predicates may depend on any of + // the captures. + captureNameFilter != null && !hasPatternPredicates + ? captureNameFilter + : undefined, + ); if (!this.runPredicates(mutableMatch)) { continue; } + const queryMatch = this.createQueryMatch(mutableMatch, captureNameFilter); + if (queryMatch != null) { results.push(queryMatch); } @@ -125,10 +152,19 @@ export class TreeSitterQuery { private createMutableQueryMatch( document: TextDocument, match: treeSitter.QueryMatch, + captureNameFilter?: Set, ): MutableQueryMatch { - return { - patternIdx: match.patternIndex, - captures: match.captures.map(({ name, node }) => ({ + const captures: MutableQueryCapture[] = []; + + for (const { name, node } of match.captures) { + if ( + captureNameFilter != null && + !captureNameFilter.has(getNormalizedCaptureName(name)) + ) { + continue; + } + + captures.push({ name, node, document, @@ -136,7 +172,12 @@ export class TreeSitterQuery { insertionDelimiter: undefined, allowMultiple: false, hasError: () => isContainedInErrorNode(node), - })), + }); + } + + return { + patternIdx: match.patternIndex, + captures, }; } From 57b37b919aaa9b43b1d60d0f980ccd89d6e3ff92 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Sat, 21 Feb 2026 19:08:20 +0100 Subject: [PATCH 06/12] Convert delimiter side to enum --- .../getIndividualDelimiters.ts | 8 ++++---- .../getSurroundingPairOccurrences.ts | 6 ++++-- .../scopeHandlers/SurroundingPairScopeHandler/types.ts | 6 +++++- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/getIndividualDelimiters.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/getIndividualDelimiters.ts index 74850d1047..a2cd5f818a 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/getIndividualDelimiters.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/getIndividualDelimiters.ts @@ -6,7 +6,7 @@ import type { import { isString } from "@cursorless/common"; import { concat, uniq } from "lodash-es"; import { complexDelimiterMap, getSimpleDelimiterMap } from "./delimiterMaps"; -import type { IndividualDelimiter } from "./types"; +import { DelimiterSide, type IndividualDelimiter } from "./types"; /** * Given a list of delimiters, returns a list where each element corresponds to @@ -55,14 +55,14 @@ function getSimpleIndividualDelimiters( const side = (() => { if (isLeft && !isRight) { - return "left"; + return DelimiterSide.left; } if (!isLeft && isRight) { - return "right"; + return DelimiterSide.right; } // If delimiter text is the same for left and right, we say its side // is "unknown", so must be determined from context. - return "unknown"; + return DelimiterSide.unknown; })(); return { diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/getSurroundingPairOccurrences.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/getSurroundingPairOccurrences.ts index 0a031d0421..da5f70944a 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/getSurroundingPairOccurrences.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/getSurroundingPairOccurrences.ts @@ -1,5 +1,6 @@ import type { Range } from "@cursorless/common"; import findLastIndex from "lodash-es/findLastIndex"; +import { DelimiterSide } from "./types"; import type { DelimiterOccurrence, IndividualDelimiter, @@ -41,7 +42,8 @@ export function getSurroundingPairOccurrences( if (closestOpeningDelimiterMatch == null) { const openingDelimiterInfo = occurrence.delimiterInfos.find( - ({ side }) => side === "left" || side === "unknown", + ({ side }) => + side === DelimiterSide.left || side === DelimiterSide.unknown, ); // Pure closing delimiters with no matching opener are ignored. @@ -84,7 +86,7 @@ function getClosestOpeningDelimiterMatch( let closestMatch: OpeningDelimiterMatch | undefined; for (const delimiterInfo of occurrence.delimiterInfos) { - if (delimiterInfo.side === "left") { + if (delimiterInfo.side === DelimiterSide.left) { continue; } diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/types.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/types.ts index 520e9630fc..963d959ecd 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/types.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/types.ts @@ -5,7 +5,11 @@ import type { Range, SimpleSurroundingPairName } from "@cursorless/common"; * or if we do not know. Note that the terms "opening" and "closing" could be * used instead of "left" and "right", respectively. */ -export type DelimiterSide = "unknown" | "left" | "right"; +export enum DelimiterSide { + unknown, + left, + right, +} /** * A description of one possible side of a delimiter From 00557432bbb7b23affee1405b3fcd1739f2308ff Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Sat, 21 Feb 2026 19:15:31 +0100 Subject: [PATCH 07/12] normalize dummy captures --- .../src/languages/TreeSitterQuery/captureNames.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/cursorless-engine/src/languages/TreeSitterQuery/captureNames.ts b/packages/cursorless-engine/src/languages/TreeSitterQuery/captureNames.ts index f012a9f9d3..c22ca69f9d 100644 --- a/packages/cursorless-engine/src/languages/TreeSitterQuery/captureNames.ts +++ b/packages/cursorless-engine/src/languages/TreeSitterQuery/captureNames.ts @@ -84,11 +84,15 @@ export function isCaptureAllowed(captureName: string): boolean { export function getNormalizedCaptureName(captureName: string): string { const normalizedCaptureName = normalizedCaptureNamesMap.get(captureName); - if (normalizedCaptureName == null) { - throw new Error( - `No normalized name for unknown capture name: ${captureName}`, - ); + if (normalizedCaptureName != null) { + return normalizedCaptureName; } - return normalizedCaptureName; + if (captureName.startsWith("_")) { + return captureName; + } + + throw new Error( + `No normalized name for unknown capture name: ${captureName}`, + ); } From a6418b4be309c1c94697861277bf89ff678dfe1e Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Sat, 21 Feb 2026 20:42:43 +0100 Subject: [PATCH 08/12] Stop caching pattern index and normalize dummy captures --- .../TreeSitterQuery/TreeSitterQuery.ts | 26 +++++-------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQuery.ts b/packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQuery.ts index e3527438d5..ccce31714f 100644 --- a/packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQuery.ts +++ b/packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQuery.ts @@ -86,27 +86,15 @@ export class TreeSitterQuery { ): QueryMatch[] { const matches = this.getTreeMatches(document, start, end); const results: QueryMatch[] = []; - const patternContainsFilteredCapture = new Map(); for (const match of matches) { - if (captureNameFilter != null) { - let hasFilteredCapture = patternContainsFilteredCapture.get( - match.patternIndex, - ); - - if (hasFilteredCapture == null) { - hasFilteredCapture = match.captures.some((capture) => - captureNameFilter.has(getNormalizedCaptureName(capture.name)), - ); - patternContainsFilteredCapture.set( - match.patternIndex, - hasFilteredCapture, - ); - } - - if (!hasFilteredCapture) { - continue; - } + if ( + captureNameFilter != null && + !match.captures.some((capture) => + captureNameFilter.has(getNormalizedCaptureName(capture.name)), + ) + ) { + continue; } const hasPatternPredicates = From 2abb390361e787c02b0e5bace99c2968cb936a34 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Sat, 21 Feb 2026 20:47:14 +0100 Subject: [PATCH 09/12] Clean up --- .../src/languages/TreeSitterQuery/captureNames.ts | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/packages/cursorless-engine/src/languages/TreeSitterQuery/captureNames.ts b/packages/cursorless-engine/src/languages/TreeSitterQuery/captureNames.ts index c22ca69f9d..eba556276e 100644 --- a/packages/cursorless-engine/src/languages/TreeSitterQuery/captureNames.ts +++ b/packages/cursorless-engine/src/languages/TreeSitterQuery/captureNames.ts @@ -82,17 +82,5 @@ export function isCaptureAllowed(captureName: string): boolean { } export function getNormalizedCaptureName(captureName: string): string { - const normalizedCaptureName = normalizedCaptureNamesMap.get(captureName); - - if (normalizedCaptureName != null) { - return normalizedCaptureName; - } - - if (captureName.startsWith("_")) { - return captureName; - } - - throw new Error( - `No normalized name for unknown capture name: ${captureName}`, - ); + return normalizedCaptureNamesMap.get(captureName) ?? captureName; } From d0bb696475c7dc3e65e562e5cc8f06d13ef8e4f2 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Sat, 21 Feb 2026 20:54:56 +0100 Subject: [PATCH 10/12] Only filter captures in one place --- .../src/languages/TreeSitterQuery/TreeSitterQuery.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQuery.ts b/packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQuery.ts index ccce31714f..16dc234a4b 100644 --- a/packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQuery.ts +++ b/packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQuery.ts @@ -106,16 +106,18 @@ export class TreeSitterQuery { // If there are pattern predicates, we need to include all captures when // creating the mutable match, since the predicates may depend on any of // the captures. - captureNameFilter != null && !hasPatternPredicates - ? captureNameFilter - : undefined, + !hasPatternPredicates ? captureNameFilter : undefined, ); if (!this.runPredicates(mutableMatch)) { continue; } - const queryMatch = this.createQueryMatch(mutableMatch, captureNameFilter); + const queryMatch = this.createQueryMatch( + mutableMatch, + // We only need to filter here if we didn't filter in createMutableQueryMatch() + hasPatternPredicates ? captureNameFilter : undefined, + ); if (queryMatch != null) { results.push(queryMatch); From 5b192a20f8cd2e96d201a03cf9ba034dfb93c928 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Sat, 21 Feb 2026 21:36:50 +0100 Subject: [PATCH 11/12] Added cache --- .../src/languages/LanguageDefinition.ts | 16 +++---- .../TreeSitterQuery/TreeSitterQuery.ts | 42 ++++++++++++++----- .../TreeSitterQuery/TreeSitterQueryCache.ts | 23 +++++++++- .../src/suite/performance.vscode.test.ts | 24 ++++++----- 4 files changed, 75 insertions(+), 30 deletions(-) diff --git a/packages/cursorless-engine/src/languages/LanguageDefinition.ts b/packages/cursorless-engine/src/languages/LanguageDefinition.ts index c926f821c5..0504719592 100644 --- a/packages/cursorless-engine/src/languages/LanguageDefinition.ts +++ b/packages/cursorless-engine/src/languages/LanguageDefinition.ts @@ -90,19 +90,19 @@ export class LanguageDefinition { * @param captureNames Optional capture names to include * @returns A map of captures in the document */ - getCapturesMap( + getCapturesMap( document: TextDocument, - captureNames?: readonly SimpleScopeTypeType[], + captureNames: readonly T[], ) { - const matches = - captureNames == null - ? this.query.matches(document) - : this.query.matchesForCaptures(document, new Set(captureNames)); - const result: Partial> = {}; + const matches = this.query.matchesForCaptures( + document, + new Set(captureNames), + ); + const result: Partial> = {}; for (const match of matches) { for (const capture of match.captures) { - const name = capture.name as SimpleScopeTypeType; + const name = capture.name as T; if (result[name] == null) { result[name] = []; } diff --git a/packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQuery.ts b/packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQuery.ts index 16dc234a4b..8d4a0412c6 100644 --- a/packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQuery.ts +++ b/packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQuery.ts @@ -64,25 +64,47 @@ export class TreeSitterQuery { start?: Position, end?: Position, ): QueryMatch[] { - if (!treeSitterQueryCache.isValid(document, start, end)) { - const matches = this.getAllMatches(document, start, end); - treeSitterQueryCache.update(document, start, end, matches); - } - return treeSitterQueryCache.get(); + return this.getMatches(document, start, end, undefined); } matchesForCaptures( document: TextDocument, captureNames: Set, ): QueryMatch[] { - return this.getAllMatches(document, undefined, undefined, captureNames); + return this.getMatches(document, undefined, undefined, captureNames); } - private getAllMatches( + private getMatches( document: TextDocument, - start?: Position, - end?: Position, - captureNameFilter?: Set, + start: Position | undefined, + end: Position | undefined, + captureNameFilter: Set | undefined, + ): QueryMatch[] { + if ( + !treeSitterQueryCache.isValid(document, start, end, captureNameFilter) + ) { + const matches = this.calculateMatches( + document, + start, + end, + captureNameFilter, + ); + treeSitterQueryCache.update( + document, + start, + end, + captureNameFilter, + matches, + ); + } + return treeSitterQueryCache.get(); + } + + private calculateMatches( + document: TextDocument, + start: Position | undefined, + end: Position | undefined, + captureNameFilter: Set | undefined, ): QueryMatch[] { const matches = this.getTreeMatches(document, start, end); const results: QueryMatch[] = []; diff --git a/packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQueryCache.ts b/packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQueryCache.ts index a2b251912e..ee229a501d 100644 --- a/packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQueryCache.ts +++ b/packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQueryCache.ts @@ -8,6 +8,7 @@ export class TreeSitterQueryCache { private startPosition: Position | undefined; private endPosition: Position | undefined; private matches: QueryMatch[] = []; + private captureNames: Set | undefined; clear() { this.documentUri = ""; @@ -15,6 +16,7 @@ export class TreeSitterQueryCache { this.documentLanguageId = ""; this.startPosition = undefined; this.endPosition = undefined; + this.captureNames = undefined; this.matches = []; } @@ -22,13 +24,15 @@ export class TreeSitterQueryCache { document: TextDocument, startPosition: Position | undefined, endPosition: Position | undefined, + captureNames: Set | undefined, ) { return ( this.documentVersion === document.version && this.documentUri === document.uri.toString() && this.documentLanguageId === document.languageId && positionsEqual(this.startPosition, startPosition) && - positionsEqual(this.endPosition, endPosition) + positionsEqual(this.endPosition, endPosition) && + setEqual(this.captureNames, captureNames) ); } @@ -36,6 +40,7 @@ export class TreeSitterQueryCache { document: TextDocument, startPosition: Position | undefined, endPosition: Position | undefined, + captureNames: Set | undefined, matches: QueryMatch[], ) { this.documentVersion = document.version; @@ -43,6 +48,7 @@ export class TreeSitterQueryCache { this.documentLanguageId = document.languageId; this.startPosition = startPosition; this.endPosition = endPosition; + this.captureNames = captureNames; this.matches = matches; } @@ -58,4 +64,19 @@ function positionsEqual(a: Position | undefined, b: Position | undefined) { return a.isEqual(b); } +function setEqual(a: Set | undefined, b: Set | undefined) { + if (a == null || b == null) { + return a === b; + } + if (a.size !== b.size) { + return false; + } + for (const item of a) { + if (!b.has(item)) { + return false; + } + } + return true; +} + export const treeSitterQueryCache = new TreeSitterQueryCache(); diff --git a/packages/cursorless-vscode-e2e/src/suite/performance.vscode.test.ts b/packages/cursorless-vscode-e2e/src/suite/performance.vscode.test.ts index 1d8f43dcb3..c5ee07db47 100644 --- a/packages/cursorless-vscode-e2e/src/suite/performance.vscode.test.ts +++ b/packages/cursorless-vscode-e2e/src/suite/performance.vscode.test.ts @@ -15,9 +15,15 @@ import { isMac } from "@cursorless/node-common"; const testData = generateTestData(100); const multiplier = calculateMultiplier(); const smallThresholdMs = 50 * multiplier; +const midThresholdMs = 200 * multiplier; const largeThresholdMs = 300 * multiplier; const xlThresholdMs = 400 * multiplier; -const thresholds = [smallThresholdMs, largeThresholdMs, xlThresholdMs]; +const thresholds = [ + smallThresholdMs, + midThresholdMs, + largeThresholdMs, + xlThresholdMs, +]; type ModifierType = "containing" | "previous" | "every"; @@ -73,19 +79,15 @@ suite(`Performance ${thresholds.join("/")} ms`, async function () { ["value", largeThresholdMs, "previous"], // Text based, but utilizes surrounding pair ["boundedParagraph", largeThresholdMs], - ["boundedNonWhitespaceSequence", largeThresholdMs], + ["boundedNonWhitespaceSequence", midThresholdMs], ["collectionItem", largeThresholdMs], ["collectionItem", largeThresholdMs, "every"], ["collectionItem", largeThresholdMs, "previous"], // Surrounding pair - [{ type: "surroundingPair", delimiter: "curlyBrackets" }, largeThresholdMs], - [{ type: "surroundingPair", delimiter: "any" }, largeThresholdMs], - [{ type: "surroundingPair", delimiter: "any" }, largeThresholdMs, "every"], - [ - { type: "surroundingPair", delimiter: "any" }, - largeThresholdMs, - "previous", - ], + [{ type: "surroundingPair", delimiter: "curlyBrackets" }, midThresholdMs], + [{ type: "surroundingPair", delimiter: "any" }, midThresholdMs], + [{ type: "surroundingPair", delimiter: "any" }, midThresholdMs, "every"], + [{ type: "surroundingPair", delimiter: "any" }, midThresholdMs, "previous"], ]; for (const [scope, threshold, modifierType] of fixtures) { @@ -111,7 +113,7 @@ suite(`Performance ${thresholds.join("/")} ms`, async function () { test( "Select collectionItem with multiple cursors", asyncSafety(() => - selectWithMultipleCursors(largeThresholdMs, { + selectWithMultipleCursors(midThresholdMs, { type: "collectionItem", }), ), From 1736e94fddf02b9a8b83315eebbbf76014719152 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Sat, 21 Feb 2026 21:53:47 +0100 Subject: [PATCH 12/12] added swap test --- .../src/suite/performance.vscode.test.ts | 43 ++++++++++++++----- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/packages/cursorless-vscode-e2e/src/suite/performance.vscode.test.ts b/packages/cursorless-vscode-e2e/src/suite/performance.vscode.test.ts index c5ee07db47..5048e59571 100644 --- a/packages/cursorless-vscode-e2e/src/suite/performance.vscode.test.ts +++ b/packages/cursorless-vscode-e2e/src/suite/performance.vscode.test.ts @@ -79,7 +79,7 @@ suite(`Performance ${thresholds.join("/")} ms`, async function () { ["value", largeThresholdMs, "previous"], // Text based, but utilizes surrounding pair ["boundedParagraph", largeThresholdMs], - ["boundedNonWhitespaceSequence", midThresholdMs], + ["boundedNonWhitespaceSequence", largeThresholdMs], ["collectionItem", largeThresholdMs], ["collectionItem", largeThresholdMs, "every"], ["collectionItem", largeThresholdMs, "previous"], @@ -128,6 +128,23 @@ suite(`Performance ${thresholds.join("/")} ms`, async function () { }), ), ); + + test( + "Swap key / value with multiple cursors", + asyncSafety(() => + testWithMultipleCursors(midThresholdMs, { + name: "swapTargets", + target1: { + type: "primitive", + modifiers: [getModifier({ type: "collectionKey" })], + }, + target2: { + type: "primitive", + modifiers: [getModifier({ type: "value" })], + }, + }), + ), + ); }); function removeToken(thresholdMs: number) { @@ -141,6 +158,19 @@ function removeToken(thresholdMs: number) { } function selectWithMultipleCursors(thresholdMs: number, scopeType: ScopeType) { + return testWithMultipleCursors(thresholdMs, { + name: "setSelection", + target: { + type: "primitive", + modifiers: [getModifier(scopeType)], + }, + }); +} + +function testWithMultipleCursors( + thresholdMs: number, + action: ActionDescriptor, +) { const beforeCallback = async (editor: vscode.TextEditor) => { await runCursorlessAction({ name: "setSelectionBefore", @@ -153,16 +183,7 @@ function selectWithMultipleCursors(thresholdMs: number, scopeType: ScopeType) { assert.equal(editor.selections.length, 100, "Expected 100 cursors"); }; - const callback = () => { - return runCursorlessAction({ - name: "setSelection", - target: { - type: "primitive", - modifiers: [getModifier(scopeType)], - }, - }); - }; - + const callback = () => runCursorlessAction(action); return testPerformanceCallback(thresholdMs, callback, beforeCallback); }