Skip to content

Commit 9cf3a1b

Browse files
Added the ability to remove whitespaces with join token (#2651)
## Checklist - [x] 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 --------- Co-authored-by: Phil Cohen <[email protected]>
1 parent d03f088 commit 9cf3a1b

File tree

9 files changed

+223
-34
lines changed

9 files changed

+223
-34
lines changed

changelog/2024-08-joinTokens.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
tags: [enhancement]
3+
pullRequest: 2651
4+
---
5+
6+
`join token` now behaves as expected. `"join two tokens air"` will now remove the whitespace between the tokens; previously, it simply joined together their lines, which was usually not expected.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
languageId: plaintext
2+
command:
3+
version: 7
4+
spokenForm: join bat past each
5+
action:
6+
name: joinLines
7+
target:
8+
type: range
9+
anchor:
10+
type: primitive
11+
mark: {type: decoratedSymbol, symbolColor: default, character: b}
12+
active:
13+
type: primitive
14+
mark: {type: decoratedSymbol, symbolColor: default, character: e}
15+
excludeAnchor: false
16+
excludeActive: false
17+
usePrePhraseSnapshot: true
18+
initialState:
19+
documentContents: |-
20+
aa bb cc
21+
dd ee ff
22+
selections:
23+
- anchor: {line: 0, character: 4}
24+
active: {line: 0, character: 4}
25+
marks:
26+
default.b:
27+
start: {line: 0, character: 3}
28+
end: {line: 0, character: 5}
29+
default.e:
30+
start: {line: 1, character: 3}
31+
end: {line: 1, character: 5}
32+
finalState:
33+
documentContents: aa bb cc dd ee ff
34+
selections:
35+
- anchor: {line: 0, character: 4}
36+
active: {line: 0, character: 4}
37+
thatMark:
38+
- type: UntypedTarget
39+
contentRange:
40+
start: {line: 0, character: 0}
41+
end: {line: 0, character: 17}
42+
isReversed: false
43+
hasExplicitRange: true
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
languageId: plaintext
2+
command:
3+
version: 7
4+
spokenForm: join three tokens
5+
action:
6+
name: joinLines
7+
target:
8+
type: primitive
9+
modifiers:
10+
- type: relativeScope
11+
scopeType: {type: token}
12+
offset: 0
13+
length: 3
14+
direction: forward
15+
usePrePhraseSnapshot: true
16+
initialState:
17+
documentContents: |-
18+
aa bb cc
19+
dd ee ff
20+
selections:
21+
- anchor: {line: 0, character: 0}
22+
active: {line: 0, character: 0}
23+
marks: {}
24+
finalState:
25+
documentContents: |-
26+
aabbcc
27+
dd ee ff
28+
selections:
29+
- anchor: {line: 0, character: 0}
30+
active: {line: 0, character: 0}
31+
thatMark:
32+
- type: UntypedTarget
33+
contentRange:
34+
start: {line: 0, character: 0}
35+
end: {line: 0, character: 6}
36+
isReversed: false
37+
hasExplicitRange: true
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
languageId: plaintext
2+
command:
3+
version: 7
4+
spokenForm: join token batt past each
5+
action:
6+
name: joinLines
7+
target:
8+
type: range
9+
anchor:
10+
type: primitive
11+
mark: {type: decoratedSymbol, symbolColor: default, character: b}
12+
modifiers:
13+
- type: preferredScope
14+
scopeType: {type: token}
15+
active:
16+
type: primitive
17+
mark: {type: decoratedSymbol, symbolColor: default, character: e}
18+
excludeAnchor: false
19+
excludeActive: false
20+
usePrePhraseSnapshot: true
21+
spokenFormError: Modifier 'preferredScope'
22+
initialState:
23+
documentContents: |-
24+
aa bb cc
25+
dd ee ff
26+
selections:
27+
- anchor: {line: 0, character: 4}
28+
active: {line: 0, character: 4}
29+
marks:
30+
default.b:
31+
start: {line: 0, character: 3}
32+
end: {line: 0, character: 5}
33+
default.e:
34+
start: {line: 1, character: 3}
35+
end: {line: 1, character: 5}
36+
finalState:
37+
documentContents: aa bbccddee ff
38+
selections:
39+
- anchor: {line: 0, character: 4}
40+
active: {line: 0, character: 4}
41+
thatMark:
42+
- type: UntypedTarget
43+
contentRange:
44+
start: {line: 0, character: 3}
45+
end: {line: 0, character: 11}
46+
isReversed: false
47+
hasExplicitRange: true

packages/cursorless-engine/src/actions/Actions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ export class Actions implements ActionRecord {
117117
this,
118118
this.modifierStageFactory,
119119
);
120-
joinLines = new JoinLines(this.rangeUpdater);
120+
joinLines = new JoinLines(this.rangeUpdater, this.modifierStageFactory);
121121
breakLine = new BreakLine(this.rangeUpdater);
122122
moveToTarget = new Move(this.rangeUpdater);
123123
outdentLine = new OutdentLine(this.rangeUpdater);
Lines changed: 76 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,53 @@
11
import type { Edit, TextEditor } from "@cursorless/common";
2-
import { FlashStyle, Range } from "@cursorless/common";
2+
import { FlashStyle, Range, zipStrict } from "@cursorless/common";
33
import { range as iterRange, map, pairwise } from "itertools";
4-
import { flatten, zip } from "lodash-es";
4+
import { flatten } from "lodash-es";
55
import type { RangeUpdater } from "../core/updateSelections/RangeUpdater";
66
import { performEditsAndUpdateSelections } from "../core/updateSelections/updateSelections";
7+
import { containingLineIfUntypedModifier } from "../processTargets/modifiers/commonContainingScopeIfUntypedModifiers";
8+
import type { ModifierStageFactory } from "../processTargets/ModifierStageFactory";
9+
import type { ModifierStage } from "../processTargets/PipelineStages.types";
710
import { ide } from "../singletons/ide.singleton";
11+
import { getMatcher } from "../tokenizer";
812
import type { Target } from "../typings/target.types";
13+
import { generateMatchesInRange } from "../util/getMatchesInRange";
914
import { flashTargets, runOnTargetsForEachEditor } from "../util/targetUtils";
1015
import type { ActionReturnValue } from "./actions.types";
1116

1217
export default class JoinLines {
13-
constructor(private rangeUpdater: RangeUpdater) {
18+
getFinalStages(): ModifierStage[] {
19+
return [this.modifierStageFactory.create(containingLineIfUntypedModifier)];
20+
}
21+
22+
constructor(
23+
private rangeUpdater: RangeUpdater,
24+
private modifierStageFactory: ModifierStageFactory,
25+
) {
1426
this.run = this.run.bind(this);
1527
}
1628

1729
async run(targets: Target[]): Promise<ActionReturnValue> {
18-
await flashTargets(ide(), targets, FlashStyle.pendingModification0);
30+
await flashTargets(
31+
ide(),
32+
targets.map(({ thatTarget }) => thatTarget),
33+
FlashStyle.pendingModification0,
34+
);
1935

2036
const thatSelections = flatten(
2137
await runOnTargetsForEachEditor(targets, async (editor, targets) => {
22-
const contentRanges = targets.map(({ contentRange }) => contentRange);
23-
24-
const { contentRanges: updatedRanges } =
38+
const { thatRanges: updatedThatRanges } =
2539
await performEditsAndUpdateSelections({
2640
rangeUpdater: this.rangeUpdater,
2741
editor: ide().getEditableTextEditor(editor),
28-
edits: getEdits(editor, contentRanges),
42+
edits: getEdits(editor, targets),
2943
selections: {
30-
contentRanges,
44+
thatRanges: targets.map(({ contentRange }) => contentRange),
3145
},
3246
});
3347

34-
return zip(targets, updatedRanges).map(([target, range]) => ({
35-
editor: target!.editor,
36-
selection: range!.toSelection(target!.isReversed),
48+
return zipStrict(targets, updatedThatRanges).map(([target, range]) => ({
49+
editor,
50+
selection: range.toSelection(target.isReversed),
3751
}));
3852
}),
3953
);
@@ -42,28 +56,60 @@ export default class JoinLines {
4256
}
4357
}
4458

45-
function getEdits(editor: TextEditor, contentRanges: Range[]): Edit[] {
46-
const { document } = editor;
59+
function getEdits(editor: TextEditor, targets: Target[]): Edit[] {
4760
const edits: Edit[] = [];
4861

49-
for (const range of contentRanges) {
50-
const startLine = range.start.line;
51-
const endLine = range.isSingleLine ? startLine + 1 : range.end.line;
62+
for (const target of targets) {
63+
const targetsEdits =
64+
target.joinAs === "line"
65+
? getLineTargetEdits(target)
66+
: getTokenTargetEdits(target);
5267

53-
const lineIter = map(iterRange(startLine, endLine + 1), (i) =>
54-
document.lineAt(i),
55-
);
56-
for (const [line1, line2] of pairwise(lineIter)) {
57-
edits.push({
58-
range: new Range(
59-
line1.rangeTrimmed?.end ?? line1.range.end,
60-
line2.rangeTrimmed?.start ?? line2.range.start,
61-
),
62-
text: line2.isEmptyOrWhitespace ? "" : " ",
63-
isReplace: true,
64-
});
65-
}
68+
edits.push(...targetsEdits);
6669
}
6770

6871
return edits;
6972
}
73+
74+
function getTokenTargetEdits(target: Target): Edit[] {
75+
const { editor, contentRange } = target;
76+
const regex = getMatcher(editor.document.languageId).tokenMatcher;
77+
const matches = generateMatchesInRange(
78+
regex,
79+
editor,
80+
contentRange,
81+
"forward",
82+
);
83+
84+
return Array.from(pairwise(matches)).map(
85+
([range1, range2]): Edit => ({
86+
range: new Range(range1.end, range2.start),
87+
text: "",
88+
isReplace: true,
89+
}),
90+
);
91+
}
92+
93+
function getLineTargetEdits(target: Target): Edit[] {
94+
const { document } = target.editor;
95+
const range = target.contentRange;
96+
const startLine = range.start.line;
97+
const endLine = range.isSingleLine
98+
? Math.min(startLine + 1, document.lineCount - 1)
99+
: range.end.line;
100+
101+
const lines = map(iterRange(startLine, endLine + 1), (i) =>
102+
document.lineAt(i),
103+
);
104+
105+
return Array.from(pairwise(lines)).map(
106+
([line1, line2]): Edit => ({
107+
range: new Range(
108+
line1.rangeTrimmed?.end ?? line1.range.end,
109+
line2.rangeTrimmed?.start ?? line2.range.start,
110+
),
111+
text: line2.isEmptyOrWhitespace ? "" : " ",
112+
isReplace: true,
113+
}),
114+
);
115+
}

packages/cursorless-engine/src/processTargets/targets/BaseTarget.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
import type {
22
EnforceUndefined,
33
InsertionMode,
4-
TargetPlainObject,
54
Range,
65
Selection,
6+
TargetPlainObject,
77
TextEditor,
88
} from "@cursorless/common";
99
import { rangeToPlainObject } from "@cursorless/common";
1010
import { isEqual } from "lodash-es";
1111
import type { EditWithRangeUpdater } from "../../typings/Types";
12-
import type { Destination, Target } from "../../typings/target.types";
12+
import type {
13+
Destination,
14+
JoinAsType,
15+
Target,
16+
} from "../../typings/target.types";
1317
import { DestinationImpl } from "./DestinationImpl";
1418
import { createContinuousRange } from "./util/createContinuousRange";
1519

@@ -49,6 +53,7 @@ export abstract class BaseTarget<
4953
isImplicit = false;
5054
isNotebookCell = false;
5155
isWord = false;
56+
joinAs: JoinAsType = "line";
5257

5358
constructor(parameters: TParameters & CommonTargetParameters) {
5459
this.state = {

packages/cursorless-engine/src/processTargets/targets/TokenTarget.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Range } from "@cursorless/common";
2+
import type { JoinAsType, Target } from "../../typings/target.types";
23
import type { CommonTargetParameters } from "./BaseTarget";
34
import { BaseTarget } from "./BaseTarget";
4-
import type { Target } from "../../typings/target.types";
55
import {
66
getTokenLeadingDelimiterTarget,
77
getTokenRemovalRange,
@@ -11,6 +11,7 @@ import {
1111
export class TokenTarget extends BaseTarget<CommonTargetParameters> {
1212
type = "TokenTarget";
1313
insertionDelimiter = " ";
14+
joinAs: JoinAsType = "token";
1415

1516
getLeadingDelimiterTarget(): Target | undefined {
1617
return getTokenLeadingDelimiterTarget(this);

packages/cursorless-engine/src/typings/target.types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import type {
2828
import type { EditWithRangeUpdater } from "./Types";
2929

3030
export type EditNewActionType = "edit" | "insertLineAfter";
31+
export type JoinAsType = "line" | "token";
3132

3233
export interface Target {
3334
/** The text editor used for all ranges */
@@ -54,6 +55,9 @@ export interface Target {
5455
/** If true this target should be treated as a word */
5556
readonly isWord: boolean;
5657

58+
/** Specifies how a target should be joined */
59+
readonly joinAs: JoinAsType;
60+
5761
/**
5862
* If `true`, then this target has an explicit scope type, and so should never
5963
* be automatically expanded to a containing scope.

0 commit comments

Comments
 (0)