Skip to content
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

Add simple command history analyzer #2163

Merged
merged 9 commits into from
Feb 1, 2024
Merged
Show file tree
Hide file tree
Changes from 2 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
4 changes: 4 additions & 0 deletions packages/common/src/cursorlessCommandIds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export const cursorlessCommandIds = [
"cursorless.toggleDecorations",
"cursorless.showScopeVisualizer",
"cursorless.hideScopeVisualizer",
"cursorless.analyzeCommandHistory",
] as const satisfies readonly `cursorless.${string}`[];

export type CursorlessCommandId = (typeof cursorlessCommandIds)[number];
Expand Down Expand Up @@ -76,6 +77,9 @@ export const cursorlessCommandDescriptions: Record<
["cursorless.hideScopeVisualizer"]: new VisibleCommand(
"Hide the scope visualizer",
),
["cursorless.analyzeCommandHistory"]: new VisibleCommand(
"Analyze collected command history",
),

["cursorless.command"]: new HiddenCommand("The core cursorless command"),
["cursorless.showQuickPick"]: new HiddenCommand(
Expand Down
2 changes: 2 additions & 0 deletions packages/cursorless-engine/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"license": "MIT",
"dependencies": {
"@cursorless/common": "workspace:*",
"glob": "^7.1.7",
"immer": "^9.0.15",
"immutability-helper": "^3.1.1",
"itertools": "^2.1.1",
Expand All @@ -27,6 +28,7 @@
"zod": "3.22.3"
},
"devDependencies": {
"@types/glob": "^7.1.3",
"@types/js-yaml": "^4.0.2",
"@types/lodash": "4.14.181",
"@types/mocha": "^10.0.3",
Expand Down
111 changes: 111 additions & 0 deletions packages/cursorless-engine/src/CommandHistoryAnalyzer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import {
CommandHistoryEntry,
Modifier,
PartialPrimitiveTargetDescriptor,
ScopeType,
} from "@cursorless/common";
import globRaw from "glob";
import { promisify } from "node:util";
import { canonicalizeAndValidateCommand } from "./core/commandVersionUpgrades/canonicalizeAndValidateCommand";
import { ide } from "./singletons/ide.singleton";
import { getPartialTargetDescriptors } from "./util/getPartialTargetDescriptors";
import { getPartialPrimitiveTargets } from "./util/getPrimitiveTargets";
import { getScopeType } from "./util/getScopeType";
import { generateCommandHistoryEntries } from "./generateCommandHistoryEntries";
import groupby from "./groupby";
import { mapAsync } from "./mapAsync";
import { asyncIteratorToList } from "./asyncIteratorToList";

export const glob = promisify(globRaw);
pokey marked this conversation as resolved.
Show resolved Hide resolved

class Period {
private readonly period: string;
private readonly actions: Record<string, number> = {};
private readonly modifiers: Record<string, number> = {};
private readonly scopeTypes: Record<string, number> = {};
private count: number = 0;

constructor(period: string, entries: CommandHistoryEntry[]) {
this.period = period;
for (const entry of entries) {
this.append(entry);
}
}

toString(): string {
return [
`[${this.period}]`,
`Total commands: ${this.count}`,
this.serializeMap("Actions", this.actions),
this.serializeMap("Modifiers", this.modifiers),
this.serializeMap("Scope types", this.scopeTypes),
].join("\n\n");
}

private serializeMap(name: string, map: Record<string, number>) {
const entries = Object.entries(map);
entries.sort((a, b) => b[1] - a[1]);
const entriesSerialized = entries
.map(([key, value]) => ` ${key}: ${value}`)
.join("\n");
return `${name} (${entries.length}):\n${entriesSerialized}`;
}

private append(entry: CommandHistoryEntry) {
this.count++;
const command = canonicalizeAndValidateCommand(entry.command);
this.incrementAction(command.action.name);

this.parsePrimitiveTargets(
getPartialPrimitiveTargets(getPartialTargetDescriptors(command.action)),
);
}

private parsePrimitiveTargets(
partialPrimitiveTargets: PartialPrimitiveTargetDescriptor[],
) {
for (const target of partialPrimitiveTargets) {
if (target.modifiers == null) {
continue;
}
for (const modifier of target.modifiers) {
this.incrementModifier(modifier);

const scopeType = getScopeType(modifier);
if (scopeType != null) {
this.incrementScope(scopeType);
}
}
}
}

private incrementAction(actionName: string) {
this.actions[actionName] = (this.actions[actionName] ?? 0) + 1;
}

private incrementModifier(modifier: Modifier) {
this.modifiers[modifier.type] = (this.modifiers[modifier.type] ?? 0) + 1;
}

private incrementScope(scopeType: ScopeType) {
this.scopeTypes[scopeType.type] =
(this.scopeTypes[scopeType.type] ?? 0) + 1;
}
}

function getMonth(entry: CommandHistoryEntry): string {
return entry.date.slice(0, 7);
}

function generatePeriods(dir: string): AsyncIterable<string> {
return mapAsync(
groupby(generateCommandHistoryEntries(dir), getMonth),
([key, entries]) => new Period(key, entries).toString(),
);
}

export async function analyzeCommandHistory(dir: string) {
const text = (await asyncIteratorToList(generatePeriods(dir))).join("\n\n");

await ide().openUntitledTextDocument({ content: text });
}
9 changes: 9 additions & 0 deletions packages/cursorless-engine/src/asyncIteratorToList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export async function asyncIteratorToList<T>(
iterator: AsyncIterable<T>
): Promise<T[]> {
const list: T[] = [];
for await (const item of iterator) {
list.push(item);
}
return list;
}
22 changes: 22 additions & 0 deletions packages/cursorless-engine/src/generateCommandHistoryEntries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { CommandHistoryEntry } from "@cursorless/common";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { glob } from "./CommandHistoryAnalyzer";

export async function* generateCommandHistoryEntries(dir: string) {
const files = await glob("*.jsonl", { cwd: dir });

for (const file of files) {
const filePath = path.join(dir, file);
const content = await readFile(filePath, "utf8");
const lines = content.split("\n");

for (const line of lines) {
if (line.length === 0) {
continue;
}

yield JSON.parse(line) as CommandHistoryEntry;
}
}
}
39 changes: 39 additions & 0 deletions packages/cursorless-engine/src/groupby.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import assert from "assert";
import groupby from "./groupby";

describe("groupby", () => {
it("should group entries based on the callback function", async () => {
const generator = async function* () {
yield 1;
yield 2;
yield 3;
yield 4;
yield 5;
};

const callback = (entry: number) => (entry % 2 === 0 ? "even" : "odd");

const result = [];
for await (const group of groupby(generator(), callback)) {
result.push(group);
}

assert.equal(result, [
[1, 3, 5],
[2, 4],
]);
});

it("should handle empty generator", async () => {
const generator = async function* () {};

const callback = (entry: number) => (entry % 2 === 0 ? "even" : "odd");

const result = [];
for await (const group of groupby(generator(), callback)) {
result.push(group);
}

assert.equal(result, []);
});
});
49 changes: 49 additions & 0 deletions packages/cursorless-engine/src/groupby.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* Given an async generator and a callback, returns an async generator that
* yields groups of items from the original generator, where each group contains
* those entries that have the same value for the callback.
*
* For example, if the original generator yields the following entries:
*
* { date: "2021-01-01", command: "foo" }
* { date: "2021-01-01", command: "bar" }
* { date: "2021-01-02", command: "baz" }
* { date: "2021-01-02", command: "qux" }
*
* and the callback is `entry => entry.date`, then the returned generator will
* yield the following groups:
*
* [
* { date: "2021-01-01", command: "foo" },
* { date: "2021-01-01", command: "bar" },
* ],
* [
* { date: "2021-01-02", command: "baz" },
* { date: "2021-01-02", command: "qux" },
* ]
*
* @param generator The generator to group
* @param callback The callback to use to determine which entries are in the same group
* @returns An async generator that yields groups of entries
*/
export default async function* groupby<T, K>(
generator: AsyncGenerator<T>,
callback: (entry: T) => K,
): AsyncGenerator<[K, T[]]> {
const groups = new Map<K, T[]>();
const keys: K[] = [];

for await (const entry of generator) {
const key = callback(entry);
const group = groups.get(key) ?? [];
group.push(entry);
if (!groups.has(key)) {
groups.set(key, group);
keys.push(key);
}
}

for (const pair of groups.entries()) {
yield pair;
}
}
1 change: 1 addition & 0 deletions packages/cursorless-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export * from "./cursorlessEngine";
export * from "./api/CursorlessEngineApi";
export * from "./CommandRunner";
export * from "./CommandHistory";
export * from "./CommandHistoryAnalyzer";
16 changes: 16 additions & 0 deletions packages/cursorless-engine/src/mapAsync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* Given an async generator and a callback, returns an async generator that
* applies the callback to each entry in the original generator and yields the
* result.
* @param generator The generator to map
* @param callback The callback to apply to each entry
* @returns An async generator that yields the result of applying the callback
*/
export async function* mapAsync<T, K>(
generator: AsyncGenerator<T>,
callback: (entry: T) => K,
): AsyncGenerator<K> {
for await (const entry of generator) {
yield callback(entry);
}
}
33 changes: 33 additions & 0 deletions packages/cursorless-engine/src/util/getScopeType.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { Modifier, ScopeType } from "@cursorless/common";

export function getScopeType(modifier: Modifier): ScopeType | undefined {
switch (modifier.type) {
case "containingScope":
case "everyScope":
case "ordinalScope":
case "relativeScope":
return modifier.scopeType;

case "interiorOnly":
case "excludeInterior":
case "visible":
case "toRawSelection":
case "inferPreviousMark":
case "keepContentFilter":
case "keepEmptyFilter":
case "leading":
case "trailing":
case "startOf":
case "endOf":
case "extendThroughStartOf":
case "extendThroughEndOf":
case "cascading":
case "range":
case "modifyIfUntyped":
return undefined;

default: {
const _exhaustiveCheck: never = modifier;
}
}
}
7 changes: 6 additions & 1 deletion packages/cursorless-vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@
"onCommand:cursorless.takeSnapshot",
"onCommand:cursorless.toggleDecorations",
"onCommand:cursorless.showScopeVisualizer",
"onCommand:cursorless.hideScopeVisualizer"
"onCommand:cursorless.hideScopeVisualizer",
"onCommand:cursorless.analyzeCommandHistory"
],
"main": "./extension.cjs",
"capabilities": {
Expand Down Expand Up @@ -124,6 +125,10 @@
"command": "cursorless.hideScopeVisualizer",
"title": "Cursorless: Hide the scope visualizer"
},
{
"command": "cursorless.analyzeCommandHistory",
"title": "Cursorless: Analyze collected command history"
},
{
"command": "cursorless.command",
"title": "Cursorless: The core cursorless command",
Expand Down
1 change: 1 addition & 0 deletions packages/cursorless-vscode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export async function activate(
context,
vscodeIDE,
commandApi,
fileSystem,
testCaseRecorder,
scopeVisualizer,
keyboardCommands,
Expand Down
7 changes: 7 additions & 0 deletions packages/cursorless-vscode/src/registerCommands.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import {
CURSORLESS_COMMAND_ID,
CursorlessCommandId,
FileSystem,
isTesting,
} from "@cursorless/common";
import {
CommandApi,
TestCaseRecorder,
analyzeCommandHistory,
showCheatsheet,
updateDefaults,
} from "@cursorless/cursorless-engine";
Expand All @@ -21,6 +23,7 @@ export function registerCommands(
extensionContext: vscode.ExtensionContext,
vscodeIde: VscodeIDE,
commandApi: CommandApi,
fileSystem: FileSystem,
testCaseRecorder: TestCaseRecorder,
scopeVisualizer: ScopeVisualizer,
keyboardCommands: KeyboardCommands,
Expand Down Expand Up @@ -67,6 +70,10 @@ export function registerCommands(
["cursorless.showScopeVisualizer"]: scopeVisualizer.start,
["cursorless.hideScopeVisualizer"]: scopeVisualizer.stop,

// Command history
["cursorless.analyzeCommandHistory"]: () =>
analyzeCommandHistory(fileSystem.cursorlessCommandHistoryDirPath),

// General keyboard commands
["cursorless.keyboard.escape"]:
keyboardCommands.keyboardHandler.cancelActiveListener,
Expand Down
Loading