Skip to content

Generalize "every" range hoisting code #1495

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 1 commit 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
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,13 @@ export interface LineNumberMark {
*/
export interface RangeMark {
type: "range";
anchor: Mark;
active: Mark;
anchor: PartialMark;
active: PartialMark;
excludeAnchor?: boolean;
excludeActive?: boolean;
}

export type Mark =
export type PartialMark =
| CursorMark
| ThatMark
| SourceMark
Expand Down Expand Up @@ -275,7 +275,7 @@ export interface PositionModifier {

export interface PartialPrimitiveTargetDescriptor {
type: "primitive";
mark?: Mark;
mark?: PartialMark;
modifiers?: Modifier[];
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,12 @@ export class CommandRunnerImpl implements CommandRunner {

const action = this.actions[actionName];

const targets = this.pipelineRunner.run(
targetDescriptors,
action.getPrePositionStages?.(...actionArgs) ?? [],
action.getFinalStages?.(...actionArgs) ?? [],
const prePositionStages =
action.getPrePositionStages?.(...actionArgs) ?? [];
const finalStages = action.getFinalStages?.(...actionArgs) ?? [];

const targets = targetDescriptors.map((targetDescriptor) =>
this.pipelineRunner.run(targetDescriptor, prePositionStages, finalStages),
);

const {
Expand Down
162 changes: 162 additions & 0 deletions packages/cursorless-engine/src/core/handleHoistedModifiers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { Modifier } from "@cursorless/common";
import produce from "immer";
import { findLastIndex } from "lodash";
import {
PrimitiveTargetDescriptor,
RangeTargetDescriptor,
} from "../typings/TargetDescriptor";

/**
* This function exists to enable hoisted modifiers, eg for constructs like
* "every line air past bat". When we detect a range target which has a hoisted
* modifier on its anchor, we do the following:
*
* 1. Split the anchor's modifier chain, so that everything after the hoisted
* modifier remains on the anchor, and we reserve the remaining modifiers.
* 2. We remove everything before and including the hoisted modifier from the
* active, if it ended up there via inference.
* 3. We modify the range target if required by the hoisted modifier. For
* example "every" ranges need to handle endpoint exclusion carefully.
* 4. We construct a new {@link TargetMark} that emits the output of the range
* target, and move the reserved modifiers to a new primitive target which
* starts with this mark.
*
* We effectively break the chain into two parts, one that gets distributed over
* anchor and active, and is applied before constructing the range, and one that
* is run with the range as its input. For example:
*
* ```
* "first token every line funk air past bat"
* ```
*
* In this case, we create an "every" range target with anchor `"funk air"` and
* active `"funk bat"`. We then apply the modifier `"first token"` to the
* resulting range.
*
* @param targetDescriptor The full range target, post-inference
* @param isAnchorMarkImplicit `true` if the anchor mark was implicit on the
* original partial target
* @returns A new target descriptor which consists of a primitive target with a
* mark that emits the output of the range target, with the hoisted modifiers
* applied to it.
*/
export function handleHoistedModifiers(
targetDescriptor: RangeTargetDescriptor,
isAnchorMarkImplicit: boolean,
): PrimitiveTargetDescriptor | RangeTargetDescriptor {
const { anchor, rangeType, active } = targetDescriptor;

if (anchor.type !== "primitive" || rangeType !== "continuous") {
return targetDescriptor;
}

const indexedModifiers = anchor.modifiers.map((v, i) => [v, i] as const);

// We iterate through the modifiers in reverse because the closest hoisted
// modifier to the range owns the range. For example if you say "every line
// every funk air past bat", the "every line" owns the range, and the "every
// funk" is applied to the output.
for (const [modifier, idx] of indexedModifiers.reverse()) {
for (const hoistedModifierType of hoistedModifierTypes) {
const acceptanceInfo = hoistedModifierType.accept(modifier);
if (acceptanceInfo.accepted) {
// We hoist the modifier and everything that comes before it. Every
// modifier that comes after it is left on the anchor (and left on the
// the active if it ended up there via inference from the anchor)
const [hoistedModifiers, unhoistedModifiers] = [
anchor.modifiers.slice(0, idx + 1),
anchor.modifiers.slice(idx + 1),
];

/**
* The input range target, transformed by removing the hoisted modifiers
* from anchor and active, and applying any required transformations
* from the hoisted modifier.
*/
let pipelineInputDescriptor: RangeTargetDescriptor = {
...targetDescriptor,
anchor:
// If they say "every line past bat", the anchor is implicit, even though
// it comes across the wire as a primitive target due to the "every line",
// which we've now removed
unhoistedModifiers.length === 0 && isAnchorMarkImplicit
? { type: "implicit" }
: {
type: "primitive",
mark: anchor.mark,
positionModifier: undefined,
modifiers: unhoistedModifiers,
},
// Remove the hoisted modifier (and everything before it) from the
// active if it ended up there from inference
active: produce(active, (draft) => {
draft.modifiers = draft.modifiers.slice(
findLastIndex(
draft.modifiers,
(modifier) => hoistedModifierType.accept(modifier).accepted,
) + 1,
);
}),
};

pipelineInputDescriptor =
acceptanceInfo.transformTarget?.(pipelineInputDescriptor) ??
pipelineInputDescriptor;

// We create a new primitive target which starts with the output of the
// range target, and has the hoisted modifiers on it
return {
type: "primitive",
mark: {
type: "target",
target: pipelineInputDescriptor,
},
positionModifier: anchor.positionModifier,
modifiers: hoistedModifiers,
};
}
}
}

return targetDescriptor;
}

interface HoistedModifierAcceptanceInfo {
accepted: boolean;

/**
* If the modifier is accepted, this function is called to transform the
* input range target. For example, "every" ranges need to handle endpoint
* exclusion carefully.
* @param target The input range target
*/
transformTarget?(target: RangeTargetDescriptor): RangeTargetDescriptor;
}

interface HoistedModifierType {
accept(modifier: Modifier): HoistedModifierAcceptanceInfo;
}

/**
* These modifiers need to be "hoisted" past range targets, ie we run them
* after running the range, rather than distributing them across anchor and
* active, the way we do with all other modifiers.
*/
const hoistedModifierTypes: HoistedModifierType[] = [
// "every" ranges, eg "every line air past bat"
{
accept(modifier: Modifier) {
return modifier.type === "everyScope"
? {
accepted: true,
transformTarget(target: RangeTargetDescriptor) {
return {
...target,
exclusionScopeType: modifier.scopeType,
};
},
}
: { accepted: false };
},
},
];
104 changes: 4 additions & 100 deletions packages/cursorless-engine/src/core/inferFullTargets.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import {
EveryScopeModifier,
ImplicitTargetDescriptor,
Mark,
Modifier,
PartialListTargetDescriptor,
PartialPrimitiveTargetDescriptor,
Expand All @@ -10,13 +8,12 @@ import {
PositionModifier,
} from "@cursorless/common";
import {
EveryRangeTargetDescriptor,
Mark,
PrimitiveTargetDescriptor,
RangeTargetDescriptor,
TargetDescriptor,
} from "../typings/TargetDescriptor";
import { findLastIndex } from "lodash";
import produce from "immer";
import { handleHoistedModifiers } from "./handleHoistedModifiers";

/**
* Performs inference on the partial targets provided by the user, using
Expand Down Expand Up @@ -80,7 +77,7 @@ function inferNonListTarget(
function inferRangeTarget(
target: PartialRangeTargetDescriptor,
previousTargets: PartialTargetDescriptor[],
): RangeTargetDescriptor {
): PrimitiveTargetDescriptor | RangeTargetDescriptor {
const fullTarget: RangeTargetDescriptor = {
type: "range",
rangeType: target.rangeType ?? "continuous",
Expand All @@ -96,100 +93,7 @@ function inferRangeTarget(
const isAnchorMarkImplicit =
target.anchor.type === "implicit" || target.anchor.mark == null;

return handleEveryRangeTarget(fullTarget, isAnchorMarkImplicit) ?? fullTarget;
}

/**
* This function exists to enable constructs like "every line air past bat".
* When we detect a range target which has an `everyScope` modifier on its
* anchor, we do the following:
*
* 1. Split the anchor's modifier chain, so that everything after the
* `everyScope` modifier remains on the anchor, and we reserve everything
* that comes before the `everyScope` modifier.
* 2. We construct a special "every" range target that we handle specially in
* {@link TargetPipeline.processEveryRangeTarget}.
* 3. We put the reserved modifiers on the "every" range target to be applied
* after we've handled the "every" range target.
* 4. We remove everything before and including the `everyScope` modifier from
* the active target.
*
* We effectively break the chain into two parts, one that is applied before
* handling the "every" range target, and one that is applied after. For
* example:
*
* ```
* "first token every line funk air past bat"
* ```
*
* In this case, we create an "every" range target with anchor `"funk air"` and
* active `"funk bat"`. We then apply the modifier `"first token"` to the
* resulting range.
*
* @param fullTarget The full range target, post-inference
* @param isAnchorMarkImplicit `true` if the anchor mark was implicit on the
* original partial target
*/
function handleEveryRangeTarget(
fullTarget: RangeTargetDescriptor,
isAnchorMarkImplicit: boolean,
): EveryRangeTargetDescriptor | null {
const { anchor, rangeType, active, excludeAnchor, excludeActive } =
fullTarget;

if (anchor.type !== "primitive" || rangeType !== "continuous") {
return null;
}

const everyScopeModifierIndex = findLastIndex(
anchor.modifiers,
({ type }) => type === "everyScope",
);

if (everyScopeModifierIndex === -1) {
return null;
}

const scopeType = (
anchor.modifiers[everyScopeModifierIndex] as EveryScopeModifier
).scopeType;

const [beforeEveryModifiers, afterEveryModifiers] = [
anchor.modifiers.slice(0, everyScopeModifierIndex),
anchor.modifiers.slice(everyScopeModifierIndex + 1),
];

return {
type: "range",
rangeType: "every",
scopeType,
anchor:
// If they say "every line past bat", the anchor is implicit, even though
// it comes across the wire as a primitive target due to the "every line",
// which we've now removed
afterEveryModifiers.length === 0 && isAnchorMarkImplicit
? { type: "implicit" }
: {
type: "primitive",
mark: anchor.mark,
positionModifier: undefined,
modifiers: afterEveryModifiers,
},
// Remove the "every" (and everything before it) from the active if it
// ended up there from inference
active: produce(active, (draft) => {
draft.modifiers = draft.modifiers.slice(
findLastIndex(
draft.modifiers,
(modifier) => modifier.type === "everyScope",
) + 1,
);
}),
modifiers: beforeEveryModifiers,
positionModifier: anchor.positionModifier,
excludeAnchor,
excludeActive,
};
return handleHoistedModifiers(fullTarget, isAnchorMarkImplicit);
}

function inferPossiblyImplicitTarget(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Mark } from "@cursorless/common";
import { Mark } from "../typings/TargetDescriptor";
import { MarkStage } from "./PipelineStages.types";

export interface MarkStageFactory {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Mark, ReadOnlyHatMap } from "@cursorless/common";
import { ReadOnlyHatMap } from "@cursorless/common";
import { StoredTargetMap } from "..";
import { MarkStageFactory } from "./MarkStageFactory";
import { MarkStage } from "./PipelineStages.types";
Expand All @@ -8,8 +8,17 @@ import LineNumberStage from "./marks/LineNumberStage";
import NothingStage from "./marks/NothingStage";
import RangeMarkStage from "./marks/RangeMarkStage";
import { StoredTargetStage } from "./marks/StoredTargetStage";
import { Mark } from "../typings/TargetDescriptor";
import { TargetPipelineRunner } from ".";
import { TargetMarkStage } from "./marks/TargetMarkStage";

export class MarkStageFactoryImpl implements MarkStageFactory {
private targetPipelineRunner!: TargetPipelineRunner;

setPipelineRunner(targetPipelineRunner: TargetPipelineRunner) {
this.targetPipelineRunner = targetPipelineRunner;
}

constructor(
private readableHatMap: ReadOnlyHatMap,
private storedTargets: StoredTargetMap,
Expand All @@ -32,6 +41,8 @@ export class MarkStageFactoryImpl implements MarkStageFactory {
return new RangeMarkStage(this, mark);
case "nothing":
return new NothingStage(mark);
case "target":
return new TargetMarkStage(this.targetPipelineRunner, mark);
}
}
}
Loading