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

Pass snippets as argument to engine #2478

Merged
merged 7 commits into from
Jul 11, 2024
Merged
Show file tree
Hide file tree
Changes from 6 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
2 changes: 1 addition & 1 deletion packages/cursorless-engine/src/actions/Actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export class Actions implements ActionRecord {
foldRegion = new Fold(this.rangeUpdater);
followLink = new FollowLink({ openAside: false });
followLinkAside = new FollowLink({ openAside: true });
generateSnippet = new GenerateSnippet();
generateSnippet = new GenerateSnippet(this.snippets);
getText = new GetText();
highlight = new Highlight();
increment = new Increment(this);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { FlashStyle, isTesting, Range } from "@cursorless/common";
import type { Snippets } from "../../core/Snippets";
import { Offsets } from "../../processTargets/modifiers/surroundingPair/types";
import { ide } from "../../singletons/ide.singleton";
import { Target } from "../../typings/target.types";
import type { Target } from "../../typings/target.types";
import { matchAll } from "../../util/regex";
import { ensureSingleTarget, flashTargets } from "../../util/targetUtils";
import { ActionReturnValue } from "../actions.types";
import type { ActionReturnValue } from "../actions.types";
import { constructSnippetBody } from "./constructSnippetBody";
import { editText } from "./editText";
import { openNewSnippetFile } from "./openNewSnippetFile";
import Substituter from "./Substituter";

/**
Expand Down Expand Up @@ -46,7 +46,7 @@ import Substituter from "./Substituter";
* confusing escaping.
*/
export default class GenerateSnippet {
constructor() {
constructor(private snippets: Snippets) {
this.run = this.run.bind(this);
}

Expand Down Expand Up @@ -228,7 +228,7 @@ export default class GenerateSnippet {
} else {
// Otherwise, we create and open a new document for the snippet in the
// user snippets dir
await openNewSnippetFile(snippetName);
await this.snippets.openNewSnippetFile(snippetName);
}

// Insert the meta-snippet
Expand Down

This file was deleted.

2 changes: 0 additions & 2 deletions packages/cursorless-engine/src/api/CursorlessEngineApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import type {
ScopeProvider,
} from "@cursorless/common";
import type { CommandRunner } from "../CommandRunner";
import type { Snippets } from "../core/Snippets";
import type { StoredTargetMap } from "../core/StoredTargets";

export interface CursorlessEngine {
Expand All @@ -16,7 +15,6 @@ export interface CursorlessEngine {
customSpokenFormGenerator: CustomSpokenFormGenerator;
storedTargets: StoredTargetMap;
hatTokenMap: HatTokenMap;
snippets: Snippets;
injectIde: (ide: IDE | undefined) => void;
runIntegrationTests: () => Promise<void>;
addCommandRunnerDecorator: (
Expand Down
225 changes: 10 additions & 215 deletions packages/cursorless-engine/src/core/Snippets.ts
Original file line number Diff line number Diff line change
@@ -1,190 +1,12 @@
import { showError, Snippet, SnippetMap, walkFiles } from "@cursorless/common";
import { readFile, stat } from "fs/promises";
import { max } from "lodash-es";
import { join } from "path";
import { ide } from "../singletons/ide.singleton";
import { mergeStrict } from "../util/object";
import { mergeSnippets } from "./mergeSnippets";

const CURSORLESS_SNIPPETS_SUFFIX = ".cursorless-snippets";
const SNIPPET_DIR_REFRESH_INTERVAL_MS = 1000;

interface DirectoryErrorMessage {
directory: string;
errorMessage: string;
}
import { Snippet, SnippetMap } from "@cursorless/common";

/**
* Handles all cursorless snippets, including core, third-party and
* user-defined. Merges these collections and allows looking up snippets by
* name.
*/
export class Snippets {
private coreSnippets!: SnippetMap;
private thirdPartySnippets: Record<string, SnippetMap> = {};
private userSnippets!: SnippetMap[];

private mergedSnippets!: SnippetMap;

private userSnippetsDir?: string;

/**
* The maximum modification time of any snippet in user snippets dir.
*
* This variable will be set to -1 if no user snippets have yet been read or
* if the user snippets path has changed.
*
* This variable will be set to 0 if the user has no snippets dir configured and
* we've already set userSnippets to {}.
*/
private maxSnippetMtimeMs: number = -1;

/**
* If the user has misconfigured their snippet dir, then we keep track of it
* so that we can show them the error message if we can't find a snippet
* later, and so that we don't show them the same error message every time
* we try to poll the directory.
*/
private directoryErrorMessage: DirectoryErrorMessage | null | undefined =
null;

constructor() {
this.updateUserSnippetsPath();

this.updateUserSnippets = this.updateUserSnippets.bind(this);
this.registerThirdPartySnippets =
this.registerThirdPartySnippets.bind(this);

const timer = setInterval(
this.updateUserSnippets,
SNIPPET_DIR_REFRESH_INTERVAL_MS,
);

ide().disposeOnExit(
ide().configuration.onDidChangeConfiguration(() => {
if (this.updateUserSnippetsPath()) {
this.updateUserSnippets();
}
}),
{
dispose() {
clearInterval(timer);
},
},
);
}

async init() {
const extensionPath = ide().assetsRoot;
const snippetsDir = join(extensionPath, "cursorless-snippets");
const snippetFiles = await getSnippetPaths(snippetsDir);
this.coreSnippets = mergeStrict(
...(await Promise.all(
snippetFiles.map(async (path) =>
JSON.parse(await readFile(path, "utf8")),
),
)),
);
await this.updateUserSnippets();
}

/**
* Updates the userSnippetsDir field if it has change, returning a boolean
* indicating whether there was an update. If there was an update, resets the
* maxSnippetMtime to -1 to ensure snippet update.
* @returns Boolean indicating whether path has changed
*/
private updateUserSnippetsPath(): boolean {
const newUserSnippetsDir = ide().configuration.getOwnConfiguration(
"experimental.snippetsDir",
);

if (newUserSnippetsDir === this.userSnippetsDir) {
return false;
}

// Reset mtime to -1 so that next time we'll update the snippets
this.maxSnippetMtimeMs = -1;

this.userSnippetsDir = newUserSnippetsDir;

return true;
}

async updateUserSnippets() {
let snippetFiles: string[];
try {
snippetFiles = this.userSnippetsDir
? await getSnippetPaths(this.userSnippetsDir)
: [];
} catch (err) {
if (this.directoryErrorMessage?.directory !== this.userSnippetsDir) {
// NB: We suppress error messages once we've shown it the first time
// because we poll the directory every second and want to make sure we
// don't show the same error message repeatedly
const errorMessage = `Error with cursorless snippets dir "${
this.userSnippetsDir
}": ${(err as Error).message}`;

showError(ide().messages, "snippetsDirError", errorMessage);

this.directoryErrorMessage = {
directory: this.userSnippetsDir!,
errorMessage,
};
}

this.userSnippets = [];
this.mergeSnippets();

return;
}

this.directoryErrorMessage = null;

const maxSnippetMtime =
max(
(await Promise.all(snippetFiles.map((file) => stat(file)))).map(
(stat) => stat.mtimeMs,
),
) ?? 0;

if (maxSnippetMtime <= this.maxSnippetMtimeMs) {
return;
}

this.maxSnippetMtimeMs = maxSnippetMtime;

this.userSnippets = await Promise.all(
snippetFiles.map(async (path) => {
try {
const content = await readFile(path, "utf8");

if (content.length === 0) {
// Gracefully handle an empty file
return {};
}

return JSON.parse(content);
} catch (err) {
showError(
ide().messages,
"snippetsFileError",
`Error with cursorless snippets file "${path}": ${
(err as Error).message
}`,
);

// We don't want snippets from all files to stop working if there is
// a parse error in one file, so we just effectively ignore this file
// once we've shown an error message
return {};
}
}),
);

this.mergeSnippets();
}
export interface Snippets {
updateUserSnippets(): Promise<void>;

/**
* Allows extensions to register third-party snippets. Calling this function
Expand All @@ -195,22 +17,7 @@ export class Snippets {
* @param extensionId The id of the extension registering the snippets.
* @param snippets The snippets to be registered.
*/
registerThirdPartySnippets(extensionId: string, snippets: SnippetMap) {
this.thirdPartySnippets[extensionId] = snippets;
this.mergeSnippets();
}

/**
* Merge core, third-party, and user snippets, with precedence user > third
* party > core.
*/
private mergeSnippets() {
this.mergedSnippets = mergeSnippets(
this.coreSnippets,
this.thirdPartySnippets,
this.userSnippets,
);
}
registerThirdPartySnippets(extensionId: string, snippets: SnippetMap): void;

/**
* Looks in merged collection of snippets for a snippet with key
Expand All @@ -219,23 +26,11 @@ export class Snippets {
* @param snippetName The name of the snippet to look up
* @returns The named snippet
*/
getSnippetStrict(snippetName: string): Snippet {
const snippet = this.mergedSnippets[snippetName];

if (snippet == null) {
let errorMessage = `Couldn't find snippet ${snippetName}. `;
getSnippetStrict(snippetName: string): Snippet;

if (this.directoryErrorMessage != null) {
errorMessage += `This could be due to: ${this.directoryErrorMessage.errorMessage}.`;
}

throw Error(errorMessage);
}

return snippet;
}
}

function getSnippetPaths(snippetsDir: string) {
return walkFiles(snippetsDir, CURSORLESS_SNIPPETS_SUFFIX);
/**
* Opens a new snippet file in the users snippet directory.
* @param snippetName The name of the snippet
*/
openNewSnippetFile(snippetName: string): Promise<void>;
}
12 changes: 5 additions & 7 deletions packages/cursorless-engine/src/cursorlessEngine.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import {
Command,
CommandServerApi,
ensureCommandShape,
FileSystem,
Hats,
IDE,
ensureCommandShape,
ScopeProvider,
} from "@cursorless/common";
import {
Expand All @@ -13,7 +13,8 @@ import {
} from "./api/CursorlessEngineApi";
import { Debug } from "./core/Debug";
import { HatTokenMapImpl } from "./core/HatTokenMapImpl";
import { Snippets } from "./core/Snippets";
import { KeyboardTargetUpdater } from "./KeyboardTargetUpdater";
import type { Snippets } from "./core/Snippets";
import { StoredTargetMap } from "./core/StoredTargets";
import { RangeUpdater } from "./core/updateSelections/RangeUpdater";
import { CustomSpokenFormGeneratorImpl } from "./generateSpokenForm/CustomSpokenFormGeneratorImpl";
Expand All @@ -30,24 +31,22 @@ import { ScopeSupportChecker } from "./scopeProviders/ScopeSupportChecker";
import { ScopeSupportWatcher } from "./scopeProviders/ScopeSupportWatcher";
import { injectIde } from "./singletons/ide.singleton";
import { TreeSitter } from "./typings/TreeSitter";
import { KeyboardTargetUpdater } from "./KeyboardTargetUpdater";
import { DisabledSnippets } from "./disabledComponents/DisabledSnippets";

export async function createCursorlessEngine(
treeSitter: TreeSitter,
ide: IDE,
hats: Hats,
commandServerApi: CommandServerApi | null,
fileSystem: FileSystem,
snippets: Snippets = new DisabledSnippets(),
): Promise<CursorlessEngine> {
injectIde(ide);

const debug = new Debug(treeSitter);

const rangeUpdater = new RangeUpdater();

const snippets = new Snippets();
snippets.init();

const hatTokenMap = new HatTokenMapImpl(
rangeUpdater,
debug,
Expand Down Expand Up @@ -123,7 +122,6 @@ export async function createCursorlessEngine(
customSpokenFormGenerator,
storedTargets,
hatTokenMap,
snippets,
injectIde,
runIntegrationTests: () =>
runIntegrationTests(treeSitter, languageDefinitions),
Expand Down
Loading
Loading