Skip to content

Commit f759734

Browse files
authored
Support #allow-multiple! predicate to enable multiple content ranges (#1507)
- Depends on #1509 - Fixes #1481 ## Checklist - [ ] I have added [tests](https://www.cursorless.org/docs/contributing/test-case-recorder/) - [ ] I have updated the [docs](https://github.com/cursorless-dev/cursorless/tree/main/docs) and [cheatsheet](https://github.com/cursorless-dev/cursorless/tree/main/cursorless-talon/src/cheatsheet) - [ ] I have not broken the cheatsheet
1 parent 0b7756e commit f759734

File tree

9 files changed

+158
-43
lines changed

9 files changed

+158
-43
lines changed

packages/cursorless-engine/src/languages/TreeSitterQuery/QueryCapture.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ export interface QueryCapture {
1515

1616
/** The range of the capture. */
1717
readonly range: Range;
18+
19+
/** Whether it is ok for the same capture to appear multiple times with the
20+
* same domain. If set to `true`, then the scope handler should merge all
21+
* captures with the same name and domain into a single scope with multiple
22+
* content ranges. */
23+
readonly allowMultiple: boolean;
1824
}
1925

2026
/**
@@ -40,6 +46,7 @@ export interface MutableQueryCapture extends QueryCapture {
4046
readonly node: SyntaxNode;
4147

4248
range: Range;
49+
allowMultiple: boolean;
4350
}
4451

4552
/**

packages/cursorless-engine/src/languages/TreeSitterQuery/TreeSitterQuery.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export class TreeSitterQuery {
7878
name,
7979
node,
8080
range: getNodeRange(node),
81+
allowMultiple: false,
8182
})),
8283
}),
8384
)
@@ -108,6 +109,7 @@ export class TreeSitterQuery {
108109
range: captures
109110
.map(({ range }) => range)
110111
.reduce((accumulator, range) => range.union(accumulator)),
112+
allowMultiple: captures.some((capture) => capture.allowMultiple),
111113
};
112114
});
113115

packages/cursorless-engine/src/languages/TreeSitterQuery/checkCaptureStartEnd.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import assert = require("assert");
55

66
interface TestCase {
77
name: string;
8-
captures: QueryCapture[];
8+
captures: Omit<QueryCapture, "allowMultiple">[];
99
isValid: boolean;
1010
expectedErrorMessageIds: string[];
1111
}
@@ -188,7 +188,13 @@ suite("checkCaptureStartEnd", () => {
188188
},
189189
};
190190

191-
const result = checkCaptureStartEnd(testCase.captures, messages);
191+
const result = checkCaptureStartEnd(
192+
testCase.captures.map((capture) => ({
193+
...capture,
194+
allowMultiple: false,
195+
})),
196+
messages,
197+
);
192198
assert(result === testCase.isValid);
193199
assert.deepStrictEqual(actualErrorIds, testCase.expectedErrorMessageIds);
194200
});

packages/cursorless-engine/src/languages/TreeSitterQuery/queryPredicateOperators.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,11 +116,23 @@ class ChildRange extends QueryPredicateOperator<ChildRange> {
116116
}
117117
}
118118

119+
class AllowMultiple extends QueryPredicateOperator<AllowMultiple> {
120+
name = "allow-multiple!" as const;
121+
schema = z.tuple([q.node]);
122+
123+
run(nodeInfo: MutableQueryCapture) {
124+
nodeInfo.allowMultiple = true;
125+
126+
return true;
127+
}
128+
}
129+
119130
export const queryPredicateOperators = [
120131
new NotType(),
121132
new NotParentType(),
122133
new IsNthChild(),
123134
new StartPosition(),
124135
new EndPosition(),
125136
new ChildRange(),
137+
new AllowMultiple(),
126138
];

packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/TreeSitterScopeHandler/BaseTreeSitterScopeHandler.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export abstract class BaseTreeSitterScopeHandler extends BaseScopeHandler {
4141
const scopes = this.query
4242
.matches(document, start, end)
4343
.map((match) => this.matchToScope(editor, match))
44-
.filter((scope): scope is TargetScope => scope != null)
44+
.filter((scope): scope is ExtendedTargetScope => scope != null)
4545
.sort((a, b) => compareTargetScopes(direction, position, a, b));
4646

4747
// Merge scopes that have the same domain into a single scope with multiple
@@ -56,11 +56,23 @@ export abstract class BaseTreeSitterScopeHandler extends BaseScopeHandler {
5656

5757
return {
5858
...equivalentScopes[0],
59+
5960
getTargets(isReversed: boolean) {
60-
return uniqWith(
61+
const targets = uniqWith(
6162
equivalentScopes.flatMap((scope) => scope.getTargets(isReversed)),
6263
(a, b) => a.isEqual(b),
6364
);
65+
66+
if (
67+
targets.length > 1 &&
68+
!equivalentScopes.every((scope) => scope.allowMultiple)
69+
) {
70+
throw Error(
71+
"Please use #allow-multiple! predicate in your query to allow multiple matches for this scope type",
72+
);
73+
}
74+
75+
return targets;
6476
},
6577
};
6678
},
@@ -78,7 +90,11 @@ export abstract class BaseTreeSitterScopeHandler extends BaseScopeHandler {
7890
protected abstract matchToScope(
7991
editor: TextEditor,
8092
match: QueryMatch,
81-
): TargetScope | undefined;
93+
): ExtendedTargetScope | undefined;
94+
}
95+
96+
export interface ExtendedTargetScope extends TargetScope {
97+
allowMultiple: boolean;
8298
}
8399

84100
/**

packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/TreeSitterScopeHandler/TreeSitterIterationScopeHandler.ts

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { ScopeType, SimpleScopeType, TextEditor } from "@cursorless/common";
22
import { TreeSitterQuery } from "../../../../languages/TreeSitterQuery";
3-
import { PlainTarget } from "../../../targets";
4-
import { TargetScope } from "../scope.types";
5-
import { BaseTreeSitterScopeHandler } from "./BaseTreeSitterScopeHandler";
6-
import { getCaptureRangeByName, getRelatedRange } from "./captureUtils";
73
import { QueryMatch } from "../../../../languages/TreeSitterQuery/QueryCapture";
4+
import { PlainTarget } from "../../../targets";
5+
import {
6+
BaseTreeSitterScopeHandler,
7+
ExtendedTargetScope,
8+
} from "./BaseTreeSitterScopeHandler";
9+
import { getRelatedCapture, getRelatedRange } from "./captureUtils";
810

911
/** Scope handler to be used for iteration scopes of tree-sitter scope types */
1012
export class TreeSitterIterationScopeHandler extends BaseTreeSitterScopeHandler {
@@ -30,26 +32,26 @@ export class TreeSitterIterationScopeHandler extends BaseTreeSitterScopeHandler
3032
protected matchToScope(
3133
editor: TextEditor,
3234
match: QueryMatch,
33-
): TargetScope | undefined {
35+
): ExtendedTargetScope | undefined {
3436
const scopeTypeType = this.iterateeScopeType.type;
3537

36-
const contentRange = getRelatedRange(match, scopeTypeType, "iteration")!;
38+
const capture = getRelatedCapture(match, scopeTypeType, "iteration", false);
3739

38-
if (contentRange == null) {
40+
if (capture == null) {
3941
// This capture was for some unrelated scope type
4042
return undefined;
4143
}
4244

45+
const { range: contentRange, allowMultiple } = capture;
46+
4347
const domain =
44-
getCaptureRangeByName(
45-
match,
46-
`${scopeTypeType}.iteration.domain`,
47-
`_.iteration.domain`,
48-
) ?? contentRange;
48+
getRelatedRange(match, scopeTypeType, "iteration.domain", false) ??
49+
contentRange;
4950

5051
return {
5152
editor,
5253
domain,
54+
allowMultiple,
5355
getTargets: (isReversed) => [
5456
new PlainTarget({
5557
editor,

packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/TreeSitterScopeHandler/TreeSitterScopeHandler.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ import { SimpleScopeType, TextEditor } from "@cursorless/common";
22
import { TreeSitterQuery } from "../../../../languages/TreeSitterQuery";
33
import { QueryMatch } from "../../../../languages/TreeSitterQuery/QueryCapture";
44
import ScopeTypeTarget from "../../../targets/ScopeTypeTarget";
5-
import { TargetScope } from "../scope.types";
65
import { CustomScopeType } from "../scopeHandler.types";
7-
import { BaseTreeSitterScopeHandler } from "./BaseTreeSitterScopeHandler";
6+
import {
7+
BaseTreeSitterScopeHandler,
8+
ExtendedTargetScope,
9+
} from "./BaseTreeSitterScopeHandler";
810
import { TreeSitterIterationScopeHandler } from "./TreeSitterIterationScopeHandler";
9-
import { getCaptureRangeByName, getRelatedRange } from "./captureUtils";
11+
import { findCaptureByName, getRelatedRange } from "./captureUtils";
1012

1113
/**
1214
* Handles scopes that are implemented using tree-sitter.
@@ -33,38 +35,48 @@ export class TreeSitterScopeHandler extends BaseTreeSitterScopeHandler {
3335
protected matchToScope(
3436
editor: TextEditor,
3537
match: QueryMatch,
36-
): TargetScope | undefined {
38+
): ExtendedTargetScope | undefined {
3739
const scopeTypeType = this.scopeType.type;
3840

39-
const contentRange = getCaptureRangeByName(match, scopeTypeType);
41+
const capture = findCaptureByName(match, scopeTypeType);
4042

41-
if (contentRange == null) {
43+
if (capture == null) {
4244
// This capture was for some unrelated scope type
4345
return undefined;
4446
}
4547

48+
const { range: contentRange, allowMultiple } = capture;
49+
4650
const domain =
47-
getRelatedRange(match, scopeTypeType, "domain") ?? contentRange;
51+
getRelatedRange(match, scopeTypeType, "domain", true) ?? contentRange;
4852

49-
const removalRange = getRelatedRange(match, scopeTypeType, "removal");
53+
const removalRange = getRelatedRange(match, scopeTypeType, "removal", true);
5054

5155
const leadingDelimiterRange = getRelatedRange(
5256
match,
5357
scopeTypeType,
5458
"leading",
59+
true,
5560
);
5661

5762
const trailingDelimiterRange = getRelatedRange(
5863
match,
5964
scopeTypeType,
6065
"trailing",
66+
true,
6167
);
6268

63-
const interiorRange = getRelatedRange(match, scopeTypeType, "interior");
69+
const interiorRange = getRelatedRange(
70+
match,
71+
scopeTypeType,
72+
"interior",
73+
true,
74+
);
6475

6576
return {
6677
editor,
6778
domain,
79+
allowMultiple,
6880
getTargets: (isReversed) => [
6981
new ScopeTypeTarget({
7082
scopeTypeType,

packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/TreeSitterScopeHandler/TreeSitterTextFragmentScopeHandler.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import { TreeSitterQuery } from "../../../../languages/TreeSitterQuery";
33
import { QueryMatch } from "../../../../languages/TreeSitterQuery/QueryCapture";
44
import { TEXT_FRAGMENT_CAPTURE_NAME } from "../../../../languages/captureNames";
55
import { PlainTarget } from "../../../targets";
6-
import { TargetScope } from "../scope.types";
7-
import { BaseTreeSitterScopeHandler } from "./BaseTreeSitterScopeHandler";
8-
import { getCaptureRangeByName } from "./captureUtils";
6+
import {
7+
BaseTreeSitterScopeHandler,
8+
ExtendedTargetScope,
9+
} from "./BaseTreeSitterScopeHandler";
10+
import { findCaptureByName } from "./captureUtils";
911

1012
/** Scope handler to be used for extracting text fragments from the perspective
1113
* of surrounding pairs */
@@ -28,20 +30,26 @@ export class TreeSitterTextFragmentScopeHandler extends BaseTreeSitterScopeHandl
2830
protected matchToScope(
2931
editor: TextEditor,
3032
match: QueryMatch,
31-
): TargetScope | undefined {
32-
const contentRange = getCaptureRangeByName(
33-
match,
34-
TEXT_FRAGMENT_CAPTURE_NAME,
35-
);
33+
): ExtendedTargetScope | undefined {
34+
const capture = findCaptureByName(match, TEXT_FRAGMENT_CAPTURE_NAME);
3635

37-
if (contentRange == null) {
36+
if (capture == null) {
3837
// This capture was for some unrelated scope type
3938
return undefined;
4039
}
4140

41+
const { range: contentRange, allowMultiple } = capture;
42+
43+
if (allowMultiple) {
44+
throw Error(
45+
"The #allow-multiple! predicate is not supported for text fragments",
46+
);
47+
}
48+
4249
return {
4350
editor,
4451
domain: contentRange,
52+
allowMultiple,
4553
getTargets: (isReversed) => [
4654
new PlainTarget({
4755
editor,
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,63 @@
11
import { QueryMatch } from "../../../../languages/TreeSitterQuery/QueryCapture";
22

3+
/**
4+
* Gets a capture that is related to the scope. For example, if the scope is
5+
* "class name", the `domain` node would be the containing class.
6+
*
7+
* @param match The match to get the range from
8+
* @param scopeTypeType The type of the scope
9+
* @param relationship The relationship to get the range for, eg "domain", or
10+
* "removal"
11+
* @param matchHasScopeType Set to `true` if this match is known to have a
12+
* capture for the given scope type
13+
* @returns A capture or undefined if no capture was found
14+
*/
15+
export function getRelatedCapture(
16+
match: QueryMatch,
17+
scopeTypeType: string,
18+
relationship: string,
19+
matchHasScopeType: boolean,
20+
) {
21+
if (matchHasScopeType) {
22+
return findCaptureByName(
23+
match,
24+
`${scopeTypeType}.${relationship}`,
25+
`_.${relationship}`,
26+
);
27+
}
28+
29+
return (
30+
findCaptureByName(match, `${scopeTypeType}.${relationship}`) ??
31+
(findCaptureByName(match, scopeTypeType) != null
32+
? findCaptureByName(match, `_.${relationship}`)
33+
: undefined)
34+
);
35+
}
36+
337
/**
438
* Gets the range of a node that is related to the scope. For example, if the
539
* scope is "class name", the `domain` node would be the containing class.
640
*
741
* @param match The match to get the range from
842
* @param scopeTypeType The type of the scope
9-
* @param relationship The relationship to get the range for, eg "domain", or "removal"
43+
* @param relationship The relationship to get the range for, eg "domain", or
44+
* "removal"
45+
* @param matchHasScopeType Set to `true` if this match is known to have a
46+
* capture for the given scope type
1047
* @returns A range or undefined if no range was found
1148
*/
12-
1349
export function getRelatedRange(
1450
match: QueryMatch,
1551
scopeTypeType: string,
1652
relationship: string,
53+
matchHasScopeType: boolean,
1754
) {
18-
return getCaptureRangeByName(
55+
return getRelatedCapture(
1956
match,
20-
`${scopeTypeType}.${relationship}`,
21-
`_.${relationship}`,
22-
);
57+
scopeTypeType,
58+
relationship,
59+
matchHasScopeType,
60+
)?.range;
2361
}
2462

2563
/**
@@ -30,8 +68,20 @@ export function getRelatedRange(
3068
* @param names The possible names of the capture to get the range for
3169
* @returns A range or undefined if no matching capture was found
3270
*/
33-
export function getCaptureRangeByName(match: QueryMatch, ...names: string[]) {
71+
export function findCaptureRangeByName(match: QueryMatch, ...names: string[]) {
72+
return findCaptureByName(match, ...names)?.range;
73+
}
74+
75+
/**
76+
* Looks in the captures of a match for a capture with one of the given names, and
77+
* returns that capture, or undefined if no matching capture was found
78+
*
79+
* @param match The match to get the range from
80+
* @param names The possible names of the capture to get the range for
81+
* @returns A range or undefined if no matching capture was found
82+
*/
83+
export function findCaptureByName(match: QueryMatch, ...names: string[]) {
3484
return match.captures.find((capture) =>
3585
names.some((name) => capture.name === name),
36-
)?.range;
86+
);
3787
}

0 commit comments

Comments
 (0)