Skip to content

Commit 845cd19

Browse files
Solve bug with ambiguous closing pair delimiter (#3185)
In ruby both of these surrounding pairs are valid `( )` `%Q( )` The ambiguity that two surrounding pairs have the same closing delimiter introduced a bug.
1 parent f219fba commit 845cd19

5 files changed

Lines changed: 142 additions & 40 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
languageId: ruby
2+
command:
3+
version: 7
4+
spokenForm: change pair
5+
action:
6+
name: clearAndSetSelection
7+
target:
8+
type: primitive
9+
modifiers:
10+
- type: containingScope
11+
scopeType: {type: surroundingPair, delimiter: any}
12+
usePrePhraseSnapshot: false
13+
initialState:
14+
documentContents: "%Q(hello)"
15+
selections:
16+
- anchor: {line: 0, character: 3}
17+
active: {line: 0, character: 3}
18+
marks: {}
19+
finalState:
20+
documentContents: ""
21+
selections:
22+
- anchor: {line: 0, character: 0}
23+
active: {line: 0, character: 0}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
languageId: ruby
2+
command:
3+
version: 7
4+
spokenForm: change pair
5+
action:
6+
name: clearAndSetSelection
7+
target:
8+
type: primitive
9+
modifiers:
10+
- type: containingScope
11+
scopeType: {type: surroundingPair, delimiter: any}
12+
usePrePhraseSnapshot: false
13+
initialState:
14+
documentContents: foo(%Q(hello))
15+
selections:
16+
- anchor: {line: 0, character: 4}
17+
active: {line: 0, character: 4}
18+
marks: {}
19+
finalState:
20+
documentContents: foo()
21+
selections:
22+
- anchor: {line: 0, character: 4}
23+
active: {line: 0, character: 4}

packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/getDelimiterOccurrences.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,12 @@ export function getDelimiterOccurrences(
3434
getSortedCaptures(capturesMap.textFragment),
3535
);
3636

37-
const delimiterTextToDelimiterInfoMap = Object.fromEntries(
38-
individualDelimiters.map((individualDelimiter) => [
39-
individualDelimiter.text,
40-
individualDelimiter,
41-
]),
42-
);
37+
const delimiterTextToDelimiterInfoMap = individualDelimiters.reduce<
38+
Record<string, IndividualDelimiter[]>
39+
>((acc, individualDelimiter) => {
40+
(acc[individualDelimiter.text] ??= []).push(individualDelimiter);
41+
return acc;
42+
}, {});
4343

4444
const regexMatches = matchAllIterator(
4545
document.getText(),
@@ -64,7 +64,7 @@ export function getDelimiterOccurrences(
6464
}
6565

6666
results.push({
67-
delimiterInfo: delimiterTextToDelimiterInfoMap[text],
67+
delimiterInfos: delimiterTextToDelimiterInfoMap[text],
6868
textFragmentRange: textFragments.getSmallestContaining(matchRange)?.range,
6969
range:
7070
ifNoErrors(pairDelimiters.getContaining(matchRange))?.range ??

packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/getSurroundingPairOccurrences.ts

Lines changed: 87 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
11
import type { Range } from "@cursorless/common";
22
import findLastIndex from "lodash-es/findLastIndex";
3-
import type { DelimiterOccurrence, SurroundingPairOccurrence } from "./types";
3+
import type {
4+
DelimiterOccurrence,
5+
IndividualDelimiter,
6+
SurroundingPairOccurrence,
7+
} from "./types";
8+
9+
interface OpeningDelimiterStackOccurrence {
10+
delimiterInfo: IndividualDelimiter;
11+
range: Range;
12+
textFragmentRange: Range | undefined;
13+
}
14+
15+
interface OpeningDelimiterMatch {
16+
delimiterInfo: IndividualDelimiter;
17+
openingDelimiterIndex: number;
18+
}
419

520
/**
621
* Given a list of occurrences of delimiters, returns a list of occurrences of
@@ -13,50 +28,91 @@ export function getSurroundingPairOccurrences(
1328
delimiterOccurrences: DelimiterOccurrence[],
1429
): SurroundingPairOccurrence[] {
1530
const result: SurroundingPairOccurrence[] = [];
16-
const openingDelimitersStack: DelimiterOccurrence[] = [];
31+
const openingDelimitersStack: OpeningDelimiterStackOccurrence[] = [];
1732

1833
for (const occurrence of delimiterOccurrences) {
19-
const {
20-
delimiterInfo: { delimiterName, side, isSingleLine },
21-
textFragmentRange,
22-
range,
23-
} = occurrence;
24-
25-
if (side === "left") {
26-
openingDelimitersStack.push(occurrence);
27-
} else {
28-
const openingDelimiterIndex = findLastIndex(
29-
openingDelimitersStack,
30-
(o) =>
31-
o.delimiterInfo.delimiterName === delimiterName &&
32-
isSameTextFragment(o.textFragmentRange, textFragmentRange) &&
33-
isValidLine(isSingleLine, o.range, range),
34+
// One token can represent multiple delimiters (eg ")" could close
35+
// `parentheses` or Ruby `%Q(`), so pick the best closing interpretation
36+
// based on currently open delimiters.
37+
const closestOpeningDelimiterMatch = getClosestOpeningDelimiterMatch(
38+
occurrence,
39+
openingDelimitersStack,
40+
);
41+
42+
if (closestOpeningDelimiterMatch == null) {
43+
const openingDelimiterInfo = occurrence.delimiterInfos.find(
44+
({ side }) => side === "left" || side === "unknown",
3445
);
3546

36-
if (openingDelimiterIndex === -1) {
37-
// When side is unknown and we can't find an opening delimiter, that means this *is* the opening delimiter.
38-
if (side === "unknown") {
39-
openingDelimitersStack.push(occurrence);
40-
}
47+
// Pure closing delimiters with no matching opener are ignored.
48+
if (openingDelimiterInfo == null) {
4149
continue;
4250
}
4351

44-
const openingDelimiter = openingDelimitersStack[openingDelimiterIndex];
45-
46-
// Pop stack up to and including the opening delimiter
47-
openingDelimitersStack.length = openingDelimiterIndex;
48-
49-
result.push({
50-
delimiterName: delimiterName,
51-
openingDelimiterRange: openingDelimiter.range,
52-
closingDelimiterRange: range,
52+
// If this token can't close anything, treat it as an opener.
53+
openingDelimitersStack.push({
54+
delimiterInfo: openingDelimiterInfo,
55+
range: occurrence.range,
56+
textFragmentRange: occurrence.textFragmentRange,
5357
});
58+
continue;
5459
}
60+
61+
const { delimiterInfo, openingDelimiterIndex } =
62+
closestOpeningDelimiterMatch;
63+
const openingDelimiter = openingDelimitersStack[openingDelimiterIndex];
64+
65+
// Pop stack up to and including the opening delimiter
66+
openingDelimitersStack.length = openingDelimiterIndex;
67+
68+
result.push({
69+
delimiterName: delimiterInfo.delimiterName,
70+
openingDelimiterRange: openingDelimiter.range,
71+
closingDelimiterRange: occurrence.range,
72+
});
5573
}
5674

5775
return result;
5876
}
5977

78+
// When multiple interpretations are possible, choose the one whose opener is
79+
// closest on the stack, which preserves normal nesting behavior.
80+
function getClosestOpeningDelimiterMatch(
81+
occurrence: DelimiterOccurrence,
82+
openingDelimitersStack: OpeningDelimiterStackOccurrence[],
83+
): OpeningDelimiterMatch | undefined {
84+
let closestMatch: OpeningDelimiterMatch | undefined;
85+
86+
for (const delimiterInfo of occurrence.delimiterInfos) {
87+
if (delimiterInfo.side === "left") {
88+
continue;
89+
}
90+
91+
const openingDelimiterIndex = findLastIndex(
92+
openingDelimitersStack,
93+
(o) =>
94+
o.delimiterInfo.delimiterName === delimiterInfo.delimiterName &&
95+
isSameTextFragment(o.textFragmentRange, occurrence.textFragmentRange) &&
96+
isValidLine(delimiterInfo.isSingleLine, o.range, occurrence.range),
97+
);
98+
99+
// No opening delimiter found for this interpretation, so skip it
100+
if (openingDelimiterIndex === -1) {
101+
continue;
102+
}
103+
104+
// If this is the closest opening delimiter so far, remember it
105+
if (
106+
closestMatch == null ||
107+
openingDelimiterIndex > closestMatch.openingDelimiterIndex
108+
) {
109+
closestMatch = { delimiterInfo, openingDelimiterIndex };
110+
}
111+
}
112+
113+
return closestMatch;
114+
}
115+
60116
function isSameTextFragment(
61117
a: Range | undefined,
62118
b: Range | undefined,

packages/cursorless-engine/src/processTargets/modifiers/scopeHandlers/SurroundingPairScopeHandler/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,9 @@ export interface IndividualDelimiter {
3838
*/
3939
export interface DelimiterOccurrence {
4040
/**
41-
* Information about the delimiter itself
41+
* Possible delimiter interpretations for this text occurrence
4242
*/
43-
delimiterInfo: IndividualDelimiter;
43+
delimiterInfos: IndividualDelimiter[];
4444

4545
/**
4646
* The range of the delimiter in the document

0 commit comments

Comments
 (0)