diff --git a/packages/common/src/ide/types/RawTreeSitterQueryProvider.ts b/packages/common/src/ide/types/RawTreeSitterQueryProvider.ts new file mode 100644 index 0000000000..f5d797fa39 --- /dev/null +++ b/packages/common/src/ide/types/RawTreeSitterQueryProvider.ts @@ -0,0 +1,20 @@ +import { Disposable } from "@cursorless/common"; + +/** + * Provides raw tree-sitter queries. These are usually read from `.scm` files + * on the filesystem, but this class abstracts away the details of how the + * queries are stored. + */ +export interface RawTreeSitterQueryProvider { + /** + * Listen for changes to queries. For now, this is only used during + * development, when we want to hot-reload queries. + */ + onChanges(listener: () => void): Disposable; + + /** + * Return the raw text of the tree-sitter query of the given name. The query + * name is the name of one of the `.scm` files in our monorepo. + */ + readQuery(name: string): Promise; +} diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index a56e80ba5b..b369884366 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -31,6 +31,7 @@ export * from "./ide/types/QuickPickOptions"; export * from "./ide/types/events.types"; export * from "./ide/types/Paths"; export * from "./ide/types/CommandHistoryStorage"; +export * from "./ide/types/RawTreeSitterQueryProvider"; export * from "./ide/types/FileSystem.types"; export * from "./types/RangeExpansionBehavior"; export * from "./types/InputBoxOptions"; diff --git a/packages/cursorless-engine/src/cursorlessEngine.ts b/packages/cursorless-engine/src/cursorlessEngine.ts index 984e0bd3e9..89d591d8ed 100644 --- a/packages/cursorless-engine/src/cursorlessEngine.ts +++ b/packages/cursorless-engine/src/cursorlessEngine.ts @@ -5,7 +5,7 @@ import { IDE, ScopeProvider, ensureCommandShape, - type FileSystem, + type RawTreeSitterQueryProvider, } from "@cursorless/common"; import { KeyboardTargetUpdater } from "./KeyboardTargetUpdater"; import { @@ -19,10 +19,15 @@ import { StoredTargetMap } from "./core/StoredTargets"; import { RangeUpdater } from "./core/updateSelections/RangeUpdater"; import { DisabledCommandServerApi } from "./disabledComponents/DisabledCommandServerApi"; import { DisabledHatTokenMap } from "./disabledComponents/DisabledHatTokenMap"; +import { DisabledLanguageDefinitions } from "./disabledComponents/DisabledLanguageDefinitions"; import { DisabledSnippets } from "./disabledComponents/DisabledSnippets"; import { DisabledTalonSpokenForms } from "./disabledComponents/DisabledTalonSpokenForms"; +import { DisabledTreeSitter } from "./disabledComponents/DisabledTreeSitter"; import { CustomSpokenFormGeneratorImpl } from "./generateSpokenForm/CustomSpokenFormGeneratorImpl"; -import { LanguageDefinitions } from "./languages/LanguageDefinitions"; +import { + LanguageDefinitionsImpl, + type LanguageDefinitions, +} from "./languages/LanguageDefinitions"; import { ModifierStageFactoryImpl } from "./processTargets/ModifierStageFactoryImpl"; import { ScopeHandlerFactoryImpl } from "./processTargets/modifiers/scopeHandlers"; import { runCommand } from "./runCommand"; @@ -39,8 +44,8 @@ import { TreeSitter } from "./typings/TreeSitter"; interface Props { ide: IDE; hats?: Hats; - treeSitter: TreeSitter; - fileSystem: FileSystem; + treeSitterQueryProvider?: RawTreeSitterQueryProvider; + treeSitter?: TreeSitter; commandServerApi?: CommandServerApi; talonSpokenForms?: TalonSpokenForms; snippets?: Snippets; @@ -49,8 +54,8 @@ interface Props { export async function createCursorlessEngine({ ide, hats, - treeSitter, - fileSystem, + treeSitterQueryProvider, + treeSitter = new DisabledTreeSitter(), commandServerApi = new DisabledCommandServerApi(), talonSpokenForms = new DisabledTalonSpokenForms(), snippets = new DisabledSnippets(), @@ -71,8 +76,13 @@ export async function createCursorlessEngine({ : new DisabledHatTokenMap(); void hatTokenMap.allocateHats(); - const languageDefinitions = new LanguageDefinitions(fileSystem, treeSitter); - await languageDefinitions.init(); + const languageDefinitions = treeSitterQueryProvider + ? await LanguageDefinitionsImpl.create( + ide, + treeSitter, + treeSitterQueryProvider, + ) + : new DisabledLanguageDefinitions(); ide.disposeOnExit( rangeUpdater, diff --git a/packages/cursorless-engine/src/disabledComponents/DisabledLanguageDefinitions.ts b/packages/cursorless-engine/src/disabledComponents/DisabledLanguageDefinitions.ts new file mode 100644 index 0000000000..20a3936c88 --- /dev/null +++ b/packages/cursorless-engine/src/disabledComponents/DisabledLanguageDefinitions.ts @@ -0,0 +1,29 @@ +import type { TextDocument, Range, Listener } from "@cursorless/common"; +import type { SyntaxNode } from "web-tree-sitter"; +import type { LanguageDefinition } from "../languages/LanguageDefinition"; +import type { LanguageDefinitions } from "../languages/LanguageDefinitions"; + +export class DisabledLanguageDefinitions implements LanguageDefinitions { + onDidChangeDefinition(_listener: Listener) { + return { dispose: () => {} }; + } + + loadLanguage(_languageId: string): Promise { + return Promise.resolve(); + } + + get(_languageId: string): LanguageDefinition | undefined { + return undefined; + } + + getNodeAtLocation( + _document: TextDocument, + _range: Range, + ): SyntaxNode | undefined { + return undefined; + } + + dispose(): void { + // Do nothing + } +} diff --git a/packages/cursorless-engine/src/disabledComponents/DisabledTreeSitter.ts b/packages/cursorless-engine/src/disabledComponents/DisabledTreeSitter.ts new file mode 100644 index 0000000000..2db6900b00 --- /dev/null +++ b/packages/cursorless-engine/src/disabledComponents/DisabledTreeSitter.ts @@ -0,0 +1,21 @@ +import type { TextDocument, Range } from "@cursorless/common"; +import type { SyntaxNode, Tree, Language } from "web-tree-sitter"; +import type { TreeSitter } from "../typings/TreeSitter"; + +export class DisabledTreeSitter implements TreeSitter { + getTree(_document: TextDocument): Tree { + throw new Error("Tree sitter not provided"); + } + + loadLanguage(_languageId: string): Promise { + return Promise.resolve(false); + } + + getLanguage(_languageId: string): Language | undefined { + throw new Error("Tree sitter not provided"); + } + + getNodeAtLocation(_document: TextDocument, _range: Range): SyntaxNode { + throw new Error("Tree sitter not provided"); + } +} diff --git a/packages/cursorless-engine/src/languages/LanguageDefinition.ts b/packages/cursorless-engine/src/languages/LanguageDefinition.ts index 83252927d4..8b1d320a09 100644 --- a/packages/cursorless-engine/src/languages/LanguageDefinition.ts +++ b/packages/cursorless-engine/src/languages/LanguageDefinition.ts @@ -1,12 +1,12 @@ import { - FileSystem, ScopeType, SimpleScopeType, showError, + type IDE, + type RawTreeSitterQueryProvider, } from "@cursorless/common"; -import { basename, dirname, join } from "pathe"; +import { dirname, join } from "pathe"; import { TreeSitterScopeHandler } from "../processTargets/modifiers/scopeHandlers"; -import { ide } from "../singletons/ide.singleton"; import { TreeSitter } from "../typings/TreeSitter"; import { matchAll } from "../util/regex"; import { TreeSitterQuery } from "./TreeSitterQuery"; @@ -36,16 +36,15 @@ export class LanguageDefinition { * id doesn't have a new-style query definition */ static async create( + ide: IDE, + treeSitterQueryProvider: RawTreeSitterQueryProvider, treeSitter: TreeSitter, - fileSystem: FileSystem, - queryDir: string, languageId: string, ): Promise { - const languageQueryPath = join(queryDir, `${languageId}.scm`); - const rawLanguageQueryString = await readQueryFileAndImports( - fileSystem, - languageQueryPath, + ide, + treeSitterQueryProvider, + `${languageId}.scm`, ); if (rawLanguageQueryString == null) { @@ -91,43 +90,42 @@ export class LanguageDefinition { * @returns The text of the query file, with all imports inlined */ async function readQueryFileAndImports( - fileSystem: FileSystem, - languageQueryPath: string, + ide: IDE, + provider: RawTreeSitterQueryProvider, + languageQueryName: string, ) { // Seed the map with the query file itself const rawQueryStrings: Record = { - [languageQueryPath]: null, + [languageQueryName]: null, }; - const doValidation = ide().runMode !== "production"; + const doValidation = ide.runMode !== "production"; // Keep reading imports until we've read all the imports. Every time we // encounter an import in a query file, we add it to the map with a value // of null, so that it will be read on the next iteration while (Object.values(rawQueryStrings).some((v) => v == null)) { - for (const [queryPath, rawQueryString] of Object.entries(rawQueryStrings)) { + for (const [queryName, rawQueryString] of Object.entries(rawQueryStrings)) { if (rawQueryString != null) { continue; } - const fileName = basename(queryPath); - - let rawQuery = await fileSystem.readBundledFile(queryPath); + let rawQuery = await provider.readQuery(queryName); if (rawQuery == null) { - if (queryPath === languageQueryPath) { + if (queryName === languageQueryName) { // If this is the main query file, then we know that this language // just isn't defined using new-style queries return undefined; } showError( - ide().messages, + ide.messages, "LanguageDefinition.readQueryFileAndImports.queryNotFound", - `Could not find imported query file ${queryPath}`, + `Could not find imported query file ${queryName}`, ); - if (ide().runMode === "test") { + if (ide.runMode === "test") { throw new Error("Invalid import statement"); } @@ -136,10 +134,10 @@ async function readQueryFileAndImports( } if (doValidation) { - validateQueryCaptures(fileName, rawQuery); + validateQueryCaptures(queryName, rawQuery); } - rawQueryStrings[queryPath] = rawQuery; + rawQueryStrings[queryName] = rawQuery; matchAll( rawQuery, // Matches lines like: @@ -154,10 +152,10 @@ async function readQueryFileAndImports( const relativeImportPath = match[1]; if (doValidation) { - validateImportSyntax(fileName, relativeImportPath, match[0]); + validateImportSyntax(ide, queryName, relativeImportPath, match[0]); } - const importQueryPath = join(dirname(queryPath), relativeImportPath); + const importQueryPath = join(dirname(queryName), relativeImportPath); rawQueryStrings[importQueryPath] = rawQueryStrings[importQueryPath] ?? null; }, @@ -169,6 +167,7 @@ async function readQueryFileAndImports( } function validateImportSyntax( + ide: IDE, file: string, relativeImportPath: string, actual: string, @@ -177,12 +176,12 @@ function validateImportSyntax( if (actual !== canonicalSyntax) { showError( - ide().messages, + ide.messages, "LanguageDefinition.readQueryFileAndImports.malformedImport", `Malformed import statement in ${file}: "${actual}". Import statements must be of the form "${canonicalSyntax}"`, ); - if (ide().runMode === "test") { + if (ide.runMode === "test") { throw new Error("Invalid import statement"); } } diff --git a/packages/cursorless-engine/src/languages/LanguageDefinitions.ts b/packages/cursorless-engine/src/languages/LanguageDefinitions.ts index 4a205950b9..afae9dd518 100644 --- a/packages/cursorless-engine/src/languages/LanguageDefinitions.ts +++ b/packages/cursorless-engine/src/languages/LanguageDefinitions.ts @@ -1,19 +1,18 @@ import { Disposable, - FileSystem, Notifier, Range, TextDocument, - getCursorlessRepoRoot, isTesting, showError, + type IDE, + type RawTreeSitterQueryProvider, + type Listener, } from "@cursorless/common"; -import { join } from "pathe"; +import { toString } from "lodash-es"; import { SyntaxNode } from "web-tree-sitter"; import { TreeSitter } from "../typings/TreeSitter"; -import { ide } from "../singletons/ide.singleton"; import { LanguageDefinition } from "./LanguageDefinition"; -import { toString } from "lodash-es"; /** * Sentinel value to indicate that a language doesn't have @@ -21,11 +20,37 @@ import { toString } from "lodash-es"; */ const LANGUAGE_UNDEFINED = Symbol("LANGUAGE_UNDEFINED"); +export interface LanguageDefinitions { + onDidChangeDefinition: (listener: Listener) => Disposable; + + loadLanguage(languageId: string): Promise; + + /** + * Get a language definition for the given language id, if the language + * has a new-style query definition, or return undefined if the language doesn't + * + * @param languageId The language id for which to get a language definition + * @returns A language definition for the given language id, or undefined if + * the given language id doesn't have a new-style query definition + */ + get(languageId: string): LanguageDefinition | undefined; + + /** + * @deprecated Only for use in legacy containing scope stage + */ + getNodeAtLocation( + document: TextDocument, + range: Range, + ): SyntaxNode | undefined; +} + /** * Keeps a map from language ids to {@link LanguageDefinition} instances, * constructing them as necessary */ -export class LanguageDefinitions { +export class LanguageDefinitionsImpl + implements LanguageDefinitions, Disposable +{ private notifier: Notifier = new Notifier(); /** @@ -42,42 +67,43 @@ export class LanguageDefinitions { string, LanguageDefinition | typeof LANGUAGE_UNDEFINED > = new Map(); - private queryDir: string; private disposables: Disposable[] = []; - constructor( - private fileSystem: FileSystem, + private constructor( + private ide: IDE, private treeSitter: TreeSitter, + private treeSitterQueryProvider: RawTreeSitterQueryProvider, ) { - ide().onDidOpenTextDocument((document) => { + ide.onDidOpenTextDocument((document) => { this.loadLanguage(document.languageId); }); - ide().onDidChangeVisibleTextEditors((editors) => { + ide.onDidChangeVisibleTextEditors((editors) => { editors.forEach(({ document }) => this.loadLanguage(document.languageId)); }); - // Use the repo root as the root for development mode, so that we can - // we can make hot-reloading work for the queries - this.queryDir = - ide().runMode === "development" - ? join(getCursorlessRepoRoot(), "queries") - : "queries"; - - if (ide().runMode === "development") { - this.disposables.push( - fileSystem.watchDir(this.queryDir, () => { - this.reloadLanguageDefinitions(); - }), - ); - } + this.disposables.push( + treeSitterQueryProvider.onChanges(() => this.reloadLanguageDefinitions()), + ); } - public async init(): Promise { - await this.loadAllLanguages(); + public static async create( + ide: IDE, + treeSitter: TreeSitter, + treeSitterQueryProvider: RawTreeSitterQueryProvider, + ) { + const instance = new LanguageDefinitionsImpl( + ide, + treeSitter, + treeSitterQueryProvider, + ); + + await instance.loadAllLanguages(); + + return instance; } private async loadAllLanguages(): Promise { - const languageIds = ide().visibleTextEditors.map( + const languageIds = this.ide.visibleTextEditors.map( ({ document }) => document.languageId, ); @@ -87,7 +113,7 @@ export class LanguageDefinitions { ); } catch (err) { showError( - ide().messages, + this.ide.messages, "Failed to load language definitions", toString(err), ); @@ -104,9 +130,9 @@ export class LanguageDefinitions { const definition = (await LanguageDefinition.create( + this.ide, + this.treeSitterQueryProvider, this.treeSitter, - this.fileSystem, - this.queryDir, languageId, )) ?? LANGUAGE_UNDEFINED; @@ -119,14 +145,6 @@ export class LanguageDefinitions { this.notifier.notifyListeners(); } - /** - * Get a language definition for the given language id, if the language - * has a new-style query definition, or return undefined if the language doesn't - * - * @param languageId The language id for which to get a language definition - * @returns A language definition for the given language id, or undefined if - * the given language id doesn't have a new-style query definition - */ get(languageId: string): LanguageDefinition | undefined { const definition = this.languageDefinitions.get(languageId); @@ -140,9 +158,6 @@ export class LanguageDefinitions { return definition === LANGUAGE_UNDEFINED ? undefined : definition; } - /** - * @deprecated Only for use in legacy containing scope stage - */ public getNodeAtLocation(document: TextDocument, range: Range): SyntaxNode { return this.treeSitter.getNodeAtLocation(document, range); } diff --git a/packages/cursorless-engine/src/processTargets/modifiers/scopeTypeStages/LegacyContainingSyntaxScopeStage.ts b/packages/cursorless-engine/src/processTargets/modifiers/scopeTypeStages/LegacyContainingSyntaxScopeStage.ts index b099e10dca..c9d693efb4 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/scopeTypeStages/LegacyContainingSyntaxScopeStage.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/scopeTypeStages/LegacyContainingSyntaxScopeStage.ts @@ -38,11 +38,15 @@ export class LegacyContainingSyntaxScopeStage implements ModifierStage { this.modifier.type === "everyScope", ); - const node: SyntaxNode | null = this.languageDefinitions.getNodeAtLocation( + const node = this.languageDefinitions.getNodeAtLocation( target.editor.document, target.contentRange, ); + if (node == null) { + throw new NoContainingScopeError(this.modifier.scopeType.type); + } + const scopeNodes = findNearestContainingAncestorNode(node, nodeMatcher, { editor: target.editor, selection: new Selection( diff --git a/packages/cursorless-engine/src/processTargets/modifiers/surroundingPair/index.ts b/packages/cursorless-engine/src/processTargets/modifiers/surroundingPair/index.ts index f81ef8ebab..635d9c1ba7 100644 --- a/packages/cursorless-engine/src/processTargets/modifiers/surroundingPair/index.ts +++ b/packages/cursorless-engine/src/processTargets/modifiers/surroundingPair/index.ts @@ -68,14 +68,14 @@ function processSurroundingPairCore( scopeType.delimiter as ComplexSurroundingPairName ] ?? [scopeType.delimiter]; - let node: SyntaxNode | null; + let node: SyntaxNode | undefined; try { node = languageDefinitions.getNodeAtLocation(document, range); // Error nodes are unreliable and should be ignored. Fall back to text based // algorithm. - if (nodeHasError(node)) { + if (node == null || nodeHasError(node)) { return findSurroundingPairTextBased( editor, range, diff --git a/packages/cursorless-vscode/src/extension.ts b/packages/cursorless-vscode/src/extension.ts index 1dda8d2528..17dd7e072e 100644 --- a/packages/cursorless-vscode/src/extension.ts +++ b/packages/cursorless-vscode/src/extension.ts @@ -19,6 +19,7 @@ import { } from "@cursorless/cursorless-engine"; import { FileSystemCommandHistoryStorage, + FileSystemRawTreeSitterQueryProvider, FileSystemTalonSpokenForms, } from "@cursorless/file-system-common"; import { @@ -92,6 +93,12 @@ export async function activate( const snippets = new VscodeSnippets(normalizedIde); void snippets.init(); + const treeSitterQueryProvider = new FileSystemRawTreeSitterQueryProvider( + normalizedIde, + fileSystem, + ); + context.subscriptions.push(treeSitterQueryProvider); + const { commandApi, storedTargets, @@ -103,10 +110,10 @@ export async function activate( customSpokenFormGenerator, } = await createCursorlessEngine({ ide: normalizedIde, - treeSitter, hats, + treeSitterQueryProvider, + treeSitter, commandServerApi, - fileSystem, talonSpokenForms, snippets, }); diff --git a/packages/file-system-common/src/FileSystemLanguageDefinitionsProvider.ts b/packages/file-system-common/src/FileSystemLanguageDefinitionsProvider.ts new file mode 100644 index 0000000000..34a146448c --- /dev/null +++ b/packages/file-system-common/src/FileSystemLanguageDefinitionsProvider.ts @@ -0,0 +1,48 @@ +import { + getCursorlessRepoRoot, + type Disposable, + type FileSystem, + type IDE, + type RawTreeSitterQueryProvider, + Notifier, +} from "@cursorless/common"; +import * as path from "pathe"; + +export class FileSystemRawTreeSitterQueryProvider + implements RawTreeSitterQueryProvider +{ + private queryDir: string; + private notifier: Notifier = new Notifier(); + private disposables: Disposable[] = []; + + constructor( + ide: IDE, + private fileSystem: FileSystem, + ) { + // Use the repo root as the root for development mode, so that we can + // we can make hot-reloading work for the queries + this.queryDir = + ide.runMode === "development" + ? path.join(getCursorlessRepoRoot(), "queries") + : "queries"; + + if (ide.runMode === "development") { + this.disposables.push( + fileSystem.watchDir(this.queryDir, () => { + this.notifier.notifyListeners(); + }), + ); + } + } + + onChanges = this.notifier.registerListener; + + readQuery(filename: string): Promise { + const queryPath = path.join(this.queryDir, filename); + return this.fileSystem.readBundledFile(queryPath); + } + + dispose() { + this.disposables.forEach((disposable) => disposable.dispose()); + } +} diff --git a/packages/file-system-common/src/index.ts b/packages/file-system-common/src/index.ts index 5c217a785d..fd4d3b8470 100644 --- a/packages/file-system-common/src/index.ts +++ b/packages/file-system-common/src/index.ts @@ -1,2 +1,3 @@ export * from "./FileSystemTalonSpokenForms"; export * from "./FileSystemCommandHistoryStorage"; +export * from "./FileSystemLanguageDefinitionsProvider";