Skip to content

Support "instance" pseudo-scope #1497

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
May 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cursorless-talon/src/modifiers/scopes.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"funk name": "functionName",
"funk": "namedFunction",
"if state": "ifStatement",
"instance": "instance",
"item": "collectionItem",
"key": "collectionKey",
"lambda": "anonymousFunction",
Expand Down
22 changes: 21 additions & 1 deletion docs/user/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,26 @@ foo.bar baz|bongo

Saying `"every paint"` would select `foo.bar` and `baz|bongo`.

##### `"instance"`

The `"instance"` modifier searches for occurrences of the text of the target. For example:

- `"take every instance air"`: selects all instances of the token with a hat over the letter `a` in the whole document
- `"take two instances air"`: selects the first two instances of the token with a hat over the letter `a`, starting from that token itself
- `"take next instance air"`: selects the next instance of the token with a hat over the letter `a`, starting from that token itself
- `"chuck every instance two tokens air"`: deletes all occurrences of the two tokens beginning from the token with a hat over the letter `a`. For example if there were a hat over the `a` in `aaa.bbb`, it would delete every occurrence of `aaa.` in the file.
- `"take every instance air past bat"`: if there were hats over the `a` and `b` in `aaa ccc bbb ddd`, it would selects all occurrences of `aaa ccc bbb` (which is the text corresponding to the range `"air past bat"`)

Note in the final example how the `"instance"` modifier constructs the instance based on the range `"air past bat"`, rather than the individual tokens `"air"` and `"bat"`, as you might expect given the way other modifiers behave. Effectively `"instance"` applies to everything after the `"instance"` modifier, rather than just the next modifier.

Note also that `"instance"` considers the type of target used to construct the instance. So for example, `"take every instance air"` will only select tokens that
are identical to the token with a hat over the letter `a`, skipping over bigger tokens that contain the token with a hat over the letter `a` as a substring. For example, if there were a hat over the `a` in `aaa`, it would select every occurrence of `aaa` in the file, but not `aaaaa`. If you want to avoid this behaviour, you can
use the `"just"` modifier, eg `"take every instance just air"`.

If your cursor is touching a token, you can say `"take every instance air"` to select all instances of the given token.

Pro tip: if you say eg `"take five instances air"`, and it turns out you need more, you can say eg `"take that and next two instances that"` to select the next two instances after the last instance you selected.

##### Surrounding pair

Cursorless has support for expanding the selection to the nearest containing paired delimiter, eg the surrounding parentheses, curly brackets, etc.
Expand Down Expand Up @@ -351,7 +371,7 @@ For example:

If your cursor / mark is between two delimiters (not adjacent to one), then saying either "left" or "right" will cause cursorless to just expand to the nearest delimiters on either side, without trying to determine whether they are opening or closing delimiters.

#### `"its"`
##### `"its"`

The the modifier `"its"` is intended to be used as part of a compound target, and will tell Cursorless to use the previously mentioned mark in the compound target.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export type SimpleScopeTypeType =
| "functionCallee"
| "functionName"
| "ifStatement"
| "instance"
| "list"
| "map"
| "name"
Expand Down
18 changes: 17 additions & 1 deletion packages/cursorless-engine/src/core/handleHoistedModifiers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,8 @@ const hoistedModifierTypes: HoistedModifierType[] = [
// "every" ranges, eg "every line air past bat"
{
accept(modifier: Modifier) {
return modifier.type === "everyScope"
return modifier.type === "everyScope" &&
modifier.scopeType.type !== "instance"
? {
accepted: true,
transformTarget(target: RangeTargetDescriptor) {
Expand All @@ -159,4 +160,19 @@ const hoistedModifierTypes: HoistedModifierType[] = [
: { accepted: false };
},
},

// "instance" modifiers treat the range as the instance to search for, eg
// "every instance air past bat" searches for instances of the text of the
// range "air past bat".
{
accept(modifier: Modifier) {
return {
accepted:
(modifier.type === "everyScope" ||
modifier.type === "relativeScope" ||
modifier.type === "ordinalScope") &&
modifier.scopeType.type === "instance",
};
},
},
];
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
KeepEmptyFilterStage,
} from "./modifiers/FilterStages";
import { HeadStage, TailStage } from "./modifiers/HeadTailStage";
import InstanceStage from "./modifiers/InstanceStage";
import {
ExcludeInteriorStage,
InteriorOnlyStage,
Expand Down Expand Up @@ -75,10 +76,22 @@ export class ModifierStageFactoryImpl implements ModifierStageFactory {
modifier,
);
case "everyScope":
if (modifier.scopeType.type === "instance") {
return new InstanceStage(this, modifier);
}

return new EveryScopeStage(this, this.scopeHandlerFactory, modifier);
case "ordinalScope":
if (modifier.scopeType.type === "instance") {
return new InstanceStage(this, modifier);
}

return new OrdinalScopeStage(this, modifier);
case "relativeScope":
if (modifier.scopeType.type === "instance") {
return new InstanceStage(this, modifier);
}

return new RelativeScopeStage(this, this.scopeHandlerFactory, modifier);
case "keepContentFilter":
return new KeepContentFilterStage(modifier);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import {
Direction,
Modifier,
OrdinalScopeModifier,
Range,
RelativeScopeModifier,
ScopeType,
TextEditor,
} from "@cursorless/common";
import { ifilter, imap, itake } from "itertools";
import { escapeRegExp } from "lodash";
import type { Target } from "../../typings/target.types";
import { generateMatchesInRange } from "../../util/getMatchesInRange";
import { ModifierStageFactory } from "../ModifierStageFactory";
import type { ModifierStage } from "../PipelineStages.types";
import { PlainTarget } from "../targets";
import { ContainingTokenIfUntypedEmptyStage } from "./ConditionalModifierStages";
import { OutOfRangeError } from "./targetSequenceUtils";

export default class InstanceStage implements ModifierStage {
constructor(
private modifierStageFactory: ModifierStageFactory,
private modifier: Modifier,
) {}

run(inputTarget: Target): Target[] {
// If the target is untyped and empty, we want to target the containing
// token. This handles the case where they just say "instance" with an empty
// selection, eg "take every instance".
const target = new ContainingTokenIfUntypedEmptyStage(
this.modifierStageFactory,
).run(inputTarget)[0];

switch (this.modifier.type) {
case "everyScope":
return this.handleEveryScope(target);
case "ordinalScope":
return this.handleOrdinalScope(target, this.modifier);
case "relativeScope":
return this.handleRelativeScope(target, this.modifier);
default:
throw Error(`${this.modifier.type} instance scope not supported`);
}
}

private handleEveryScope(target: Target): Target[] {
const { editor } = target;

return Array.from(
this.getTargetIterable(
target,
editor,
this.getEveryRange(editor),
"forward",
),
);
}

private handleOrdinalScope(
target: Target,
{ start, length }: OrdinalScopeModifier,
): Target[] {
const { editor } = target;

return takeFromOffset(
this.getTargetIterable(
target,
editor,
this.getEveryRange(editor),
start >= 0 ? "forward" : "backward",
),
start >= 0 ? start : -(length + start),
length,
);
}

private handleRelativeScope(
target: Target,
{ direction, offset, length }: RelativeScopeModifier,
): Target[] {
const { editor } = target;

const iterationRange =
direction === "forward"
? new Range(target.contentRange.start, editor.document.range.end)
: new Range(editor.document.range.start, target.contentRange.end);

return takeFromOffset(
this.getTargetIterable(target, editor, iterationRange, direction),
offset,
length,
);
}

private getEveryRange(editor: TextEditor): Range {
return editor.document.range;
}

private getTargetIterable(
target: Target,
editor: TextEditor,
searchRange: Range,
direction: Direction,
): Iterable<Target> {
const iterable = imap(
generateMatchesInRange(
new RegExp(escapeRegExp(target.contentText), "g"),
editor,
searchRange,
direction,
),
(range) =>
new PlainTarget({
contentRange: range,
editor,
isReversed: false,
isToken: false,
}),
);

const filterScopeType = getFilterScopeType(target);

if (filterScopeType != null) {
// If the target is a line, token or word, we want to filter out any
// instances of the text that are not also a line, token or word. For
// those that are, we want to return the target as a line, token or word
// target. For example, if the user says "take every instance air", we
// won't return matches where we find the text of the "air" token as part
// of a larger token.
const containingScopeModifier = this.modifierStageFactory.create({
type: "containingScope",
scopeType: filterScopeType,
});

return ifilter(
imap(iterable, (target) => {
try {
// Just try to expand to the containing scope. If it fails or is not
// equal to the target, we know the match is not a line, token or
// word.
const containingScope = containingScopeModifier.run(target);

if (
containingScope.length === 1 &&
containingScope[0].contentRange.isRangeEqual(target.contentRange)
) {
return containingScope[0];
}

return null;
} catch (err) {
return null;
}
}),
(target): target is Target => target != null,
) as Iterable<Target>;
}

return iterable;
}
}

function getFilterScopeType(target: Target): ScopeType | null {
if (target.isLine) {
return { type: "line" };
}

if (target.isToken) {
return { type: "token" };
}

if (target.isWord) {
return { type: "word" };
}

return null;
}

/**
* Take `length` items from `iterable` starting at `offset`, throwing an error
* if there are not enough items.
*
* @param iterable The iterable to take from
* @param offset How many items to skip
* @param count How many items to take
* @returns An array of length `length` containing the items from `iterable`
* starting at `offset`
*/
function takeFromOffset<T>(
iterable: Iterable<T>,
offset: number,
count: number,
): T[] {
// Skip the first `offset` targets
Array.from(itake(offset, iterable));

// Take the next `length` targets
const items = Array.from(itake(count, iterable));

if (items.length < count) {
throw new OutOfRangeError();
}

return items;
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ export class ScopeHandlerFactoryImpl implements ScopeHandlerFactory {
return new ParagraphScopeHandler(scopeType, languageId);
case "custom":
return scopeType.scopeHandler;
case "instance":
// Handle instance pseudoscope with its own special modifier
throw Error("Unexpected scope type 'instance'");
default:
return this.languageDefinitions
.get(languageId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export default abstract class BaseTarget implements Target {
isRaw = false;
isImplicit = false;
isNotebookCell = false;
isWord = false;

constructor(parameters: CommonTargetParameters) {
this.state = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export default class SubTokenWordTarget extends BaseTarget {
private trailingDelimiterRange_?: Range;
insertionDelimiter: string;
isToken = false;
isWord = true;

constructor(parameters: SubTokenTargetParameters) {
super(parameters);
Expand Down
3 changes: 3 additions & 0 deletions packages/cursorless-engine/src/typings/target.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ export interface Target {
/** If true this target should be treated as a token */
readonly isToken: boolean;

/** If true this target should be treated as a word */
readonly isWord: boolean;

/**
* If `true`, then this target has an explicit scope type, and so should never
* be automatically expanded to a containing scope.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
languageId: plaintext
command:
version: 5
spokenForm: clear second last instance air
action: {name: clearAndSetSelection}
targets:
- type: primitive
modifiers:
- type: ordinalScope
scopeType: {type: instance}
start: -2
length: 1
mark: {type: decoratedSymbol, symbolColor: default, character: a}
usePrePhraseSnapshot: true
initialState:
documentContents: |
aaa bbb ccc aaa ddd aaa eee
selections:
- anchor: {line: 1, character: 0}
active: {line: 1, character: 0}
marks:
default.a:
start: {line: 0, character: 20}
end: {line: 0, character: 23}
finalState:
documentContents: |
aaa bbb ccc ddd aaa eee
selections:
- anchor: {line: 0, character: 12}
active: {line: 0, character: 12}
Loading