Skip to content
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

# [Unreleased]

## Fixed

- Add 'Remove' option to notification if local parser installation fails to load.

# 0.7.0

Finally, switched to `node-tree-sitter`! 🎉
Expand Down
7 changes: 1 addition & 6 deletions src/FileTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { Language } from "./Installer";
import { Selection } from "./Selection";
import { getLanguageConfig } from "./configuration";
import { getLogger } from "./outputChannel";
import { parserFinishedInit } from "./extension";

function positionToPoint(pos: vscode.Position): Parser.Point {
return {
Expand Down Expand Up @@ -72,11 +71,7 @@ export class FileTree implements vscode.Disposable {
);
}

public static async new(
language: Language,
document: vscode.TextDocument
): Promise<Result<FileTree, unknown>> {
await parserFinishedInit;
public static new(language: Language, document: vscode.TextDocument): Result<FileTree, unknown> {
const parser = new Parser();
const logger = getLogger();
try {
Expand Down
89 changes: 57 additions & 32 deletions src/Installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,10 @@ import * as tar from "tar";
import * as vscode from "vscode";
import { ExecException, ExecOptions, exec } from "child_process";
import { Result, err, ok } from "./result";
import { existsSync, rmSync } from "fs";
import Parser from "tree-sitter";
import { existsSync } from "fs";
import { getLogger } from "./outputChannel";
import { mkdir } from "fs/promises";
import { parserFinishedInit } from "./extension";
import which from "which";

const NPM_INSTALL_URL = "https://nodejs.org/en/download";
Expand All @@ -23,46 +22,45 @@ export function getAbsoluteBindingsDir(parsersDir: string, parserName: string):
return path.resolve(path.join(parsersDir, parserName, "bindings", "node", "index.js"));
}

export async function loadParser(
export function loadParser(
parsersDir: string,
parserName: string,
subdirectory?: string
): Promise<Result<Language, string>> {
): Result<Language, string> {
const logger = getLogger();

const bindingsDir = getAbsoluteBindingsDir(parsersDir, parserName);
if (!existsSync(bindingsDir)) {
const msg = `Expected parser directory doesn't exist: ${bindingsDir}`;
logger.log(msg);
return err(msg);
} else {
await parserFinishedInit;
try {
logger.log(`Loading parser from ${bindingsDir}`);
}

// using dynamic import causes issues on windows
// make sure to test well on windows before changing this
// TODO(02/11/24): change to dynamic import
// let { default: language } = (await import(bindingsDir)) as { default: Language };
try {
logger.log(`Loading parser from ${bindingsDir}`);

// eslint-disable-next-line @typescript-eslint/no-var-requires
let language = require(bindingsDir) as Language;
// using dynamic import causes issues on windows
// make sure to test well on windows before changing this
// TODO(02/11/24): change to dynamic import
// let { default: language } = (await import(bindingsDir)) as { default: Language };

logger.log(`Got language: ${JSON.stringify(Object.keys(language))}`);
// eslint-disable-next-line @typescript-eslint/no-var-requires
let language = require(bindingsDir) as Language;

if (subdirectory !== undefined) {
logger.log(`Loading subdirectory: ${subdirectory}`);
// @ts-expect-error we know this is a language
language = language[subdirectory] as Language;
logger.log(`Got language: ${JSON.stringify(Object.keys(language))}`);

logger.log(`Got subdirectory language: ${JSON.stringify(Object.keys(language))}`);
}
if (subdirectory !== undefined) {
logger.log(`Loading subdirectory: ${subdirectory}`);
// @ts-expect-error we know this is a language
language = language[subdirectory] as Language;

return ok(language);
} catch (error) {
logger.log(`Failed to load ${bindingsDir} > ${JSON.stringify(error)}`);
return err(`Failed to load ${bindingsDir} > ${JSON.stringify(error)}`);
logger.log(`Got subdirectory language: ${JSON.stringify(Object.keys(language))}`);
}

return ok(language);
} catch (error) {
logger.log(`Failed to load ${bindingsDir} > ${JSON.stringify(error)}`);
return err(`Failed to load ${bindingsDir} > ${JSON.stringify(error)}`);
}
}

Expand Down Expand Up @@ -120,7 +118,7 @@ export async function downloadAndBuildParser(
}

// try to load parser optimistically
const loadResult = await loadParser(parsersDir, parserName);
const loadResult = loadParser(parsersDir, parserName);
if (loadResult.status === "ok") {
return ok(undefined);
}
Expand Down Expand Up @@ -186,11 +184,16 @@ async function runCmd(
});
}

export type GetLanguageError = {
cause: "downloadFailed" | "loadFailed";
msg: string;
};

export async function getLanguage(
parsersDir: string,
languageId: string,
autoInstall = false
): Promise<Result<Language | undefined, string>> {
): Promise<Result<Language | undefined, GetLanguageError>> {
const logger = getLogger();

const ignoredLanguageIds = configuration.getIgnoredLanguageIds();
Expand All @@ -205,8 +208,6 @@ export async function getLanguage(
const npm = "npm";
const treeSitterCli = configuration.getTreeSitterCliPath();

await parserFinishedInit;

if (!existsSync(parserPackagePath)) {
const doInstall = autoInstall
? "Yes"
Expand Down Expand Up @@ -268,18 +269,42 @@ export async function getLanguage(
const msg = `Failed to download/build parser for language ${languageId} > ${downloadResult.result}`;

logger.log(msg);
return err(msg);
return err({ cause: "downloadFailed", msg });
}
}

const loadResult = await loadParser(parsersDir, parserName, subdirectory);
const loadResult = loadParser(parsersDir, parserName, subdirectory);
if (loadResult.status === "err") {
const msg = `Failed to load parser for language ${languageId} > ${loadResult.result}`;

logger.log(msg);
return err(msg);
return err({ cause: "loadFailed", msg });
}

logger.log(`Successfully loaded parser for language ${languageId}`);
return ok(loadResult.result);
}

export async function askRemoveLanguage(parsersDir: string, languageId: string, msg: string): Promise<void> {
const doRemove = await vscode.window.showErrorMessage(
`Failed to load parser for ${languageId}: ${msg}`,
"Remove",
"Ok"
);

if (doRemove === "Remove") {
removeLanguage(parsersDir, languageId);

Copilot AI Oct 21, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing await keyword. The removeLanguage function performs file system operations (rmSync) that could throw errors. While rmSync is synchronous, the call should be awaited or the function should handle potential errors, especially since this is called from an async context where error handling would be expected.

Suggested change
removeLanguage(parsersDir, languageId);
try {
removeLanguage(parsersDir, languageId);
} catch (err) {
const logger = getLogger();
logger.log(`Failed to remove parser for ${languageId}: ${err}`);
vscode.window.showErrorMessage(`Failed to remove parser for ${languageId}: ${err}`);
}

Copilot uses AI. Check for mistakes.
}
}

export function removeLanguage(parsersDir: string, languageId: string): void {
const logger = getLogger();

const { parserName } = configuration.getLanguageConfig(languageId);
const parserPackagePath = getAbsoluteParserDir(parsersDir, parserName);

if (existsSync(parserPackagePath)) {
rmSync(parserPackagePath, { recursive: true, force: true });
}
logger.log(`Removed parser '${parserPackagePath}'`);
}
27 changes: 17 additions & 10 deletions src/editor/CodeBlocksEditorProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,24 @@ export class CodeBlocksEditorProvider implements vscode.CustomTextEditorProvider
let language = await Installer.getLanguage(this.extensionParsersDirPath, languageId);

while (language.status !== "ok") {
const items =
language.result.cause === "loadFailed"
? (["Remove", "Ok"] as const)
: (["Retry", "Ok"] as const);

const choice = await vscode.window.showErrorMessage(
`Parser installation failed: ${language.result}`,
"Retry",
"Ok"
`Parser installation failed: ${JSON.stringify(language.result)}`,
...items
);
if (choice !== "Retry") {

if (choice === "Remove") {
Installer.removeLanguage(this.extensionParsersDirPath, languageId);

Copilot AI Oct 21, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The removeLanguage function can throw errors during file system operations, but this call is not wrapped in error handling. Consider adding try-catch or propagating errors to inform the user if removal fails.

Suggested change
Installer.removeLanguage(this.extensionParsersDirPath, languageId);
try {
Installer.removeLanguage(this.extensionParsersDirPath, languageId);
} catch (err) {
await vscode.window.showErrorMessage(
`Failed to remove language '${languageId}': ${err instanceof Error ? err.message : String(err)}`
);
}

Copilot uses AI. Check for mistakes.
return;
} else if (choice === "Retry") {
language = await Installer.getLanguage(this.extensionParsersDirPath, languageId);
} else {
return;
}

language = await Installer.getLanguage(this.extensionParsersDirPath, languageId);
}

if (language.result === undefined) {
Expand All @@ -52,11 +60,10 @@ export class CodeBlocksEditorProvider implements vscode.CustomTextEditorProvider
for (const query of languageQueries) {
queries.push(new Query(language.result, query));
}
const fileTreeResult = await FileTree.new(language.result, document);
const fileTreeResult = FileTree.new(language.result, document);
if (fileTreeResult.status === "err") {
await vscode.window.showErrorMessage(
`Failed to load parser for ${languageId}: ${JSON.stringify(fileTreeResult.result)}`
);
const msg = JSON.stringify(fileTreeResult.result);
await Installer.askRemoveLanguage(this.extensionParsersDirPath, languageId, msg);
return;
}

Expand Down
49 changes: 35 additions & 14 deletions src/extension.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import * as BlockMode from "./BlockMode";
import * as Installer from "./Installer";
import * as configuration from "./configuration";
import * as vscode from "vscode";
import { CodeBlocksEditorProvider } from "./editor/CodeBlocksEditorProvider";
import { FileTree } from "./FileTree";
import { TreeViewer } from "./TreeViewer";
import { getLanguage } from "./Installer";
import { getLogger } from "./outputChannel";
import { join } from "path";
import { state } from "./state";

export const parserFinishedInit = Promise.resolve();

async function reopenWithCodeBocksEditor(): Promise<void> {
const activeTabInput = vscode.window.tabGroups.activeTabGroup.activeTab?.input as {
[key: string]: unknown;
Expand Down Expand Up @@ -45,25 +44,47 @@ async function getEditorFileTree(
}

const activeDocument = editor.document;
const language = await getLanguage(parsersDir, activeDocument.languageId);
if (language.status === "err" || language.result === undefined) {
if (language.status === "err") {
void vscode.window.showErrorMessage(`Failed to get language: ${language.result}`);
} else {
logger.log(`No language found for ${activeDocument.languageId}`);
const languageId = activeDocument.languageId;
const language = await Installer.getLanguage(parsersDir, languageId);

// sup-optimal conditional to make tsc happy

Copilot AI Oct 21, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Corrected spelling of 'sup-optimal' to 'sub-optimal'.

Suggested change
// sup-optimal conditional to make tsc happy
// sub-optimal conditional to make tsc happy

Copilot uses AI. Check for mistakes.
// tl;dr this is handling logic for 'language not received' scenarios
if (language.result === undefined || language.status === "err") {
if (language.status === "ok") {
logger.log(`No language found for ${languageId}`);
return undefined;
}

return undefined;
switch (language.result.cause) {
case "downloadFailed": {
const doIgnore = await vscode.window.showErrorMessage(
`Failed to download language: ${language.result.msg}`,
"Add to ignore",
"Ok"
);

if (doIgnore === "Add to ignore") {
// fail silently if we can't add to ignore list
// we don't want to have two consecutive error messages
await configuration.addIgnoredLanguageId(languageId);
}

return undefined;
}

case "loadFailed": {
await Installer.askRemoveLanguage(parsersDir, languageId, language.result.msg);
return undefined;
}
}
}

const tree = await FileTree.new(language.result, activeDocument);
const tree = FileTree.new(language.result, activeDocument);
if (tree.status === "ok") {
return tree.result;
}

void vscode.window.showErrorMessage(
`Failed to load parser for ${activeDocument.languageId}: ${JSON.stringify(tree.result)}`
);
await Installer.askRemoveLanguage(parsersDir, languageId, JSON.stringify(tree.result));

return undefined;
}
Expand Down
2 changes: 1 addition & 1 deletion src/test/suite/Installer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export async function testParser(language: string, content?: string): Promise<vo
// fail the test if the parser could not be installed
const result = await Installer.getLanguage("test-parsers", language, true);
if (result.status === "err") {
throw new Error(`Failed to install language: ${result.result}`);
throw new Error(`Failed to install language: ${JSON.stringify(result.result)}`);
}

// check the language can be set
Expand Down
Loading