feat(editor): mark syntax errors in files no language server checks (#1087)

Without a running language server (none configured for the language, or not installed, stopped or crashed), the editor showed no syntax errors at all, though the built-in parser already finds them. The editor now reports the parser's errors for such files, checked again after edits and when a language server starts or stops; while a server runs for the language it reports syntax errors itself. SQL is left to the database support. The check is the built-in Syntax errors inspection.

Fixes #824
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-04 03:02:32 +08:00
1 джерело 0dffad814a
коміт 2ba07341c2
5 files changed
+208 -1

No files matched your search

+2
Переглянути файл
@@ -32,6 +32,7 @@ import { registerActionsOnSave, registerFormatOnSave, registerLspActions } from
import { lspTheme } from "./theme";
import { inspectionWidgetExtension } from "./inspectionWidget";
import { unresolvedReferences } from "./fileReferences";
import { syntaxErrorsExtension } from "./syntaxErrors";
import { spellingExtension } from "./spelling";
import { DocumentationWindow, LspPopups, UsagesStatus } from "./Popups";
import { DOCUMENTATION_WINDOW } from "./docs";
@@ -58,6 +59,7 @@ export function lspExtensions(ctx: EditorDocContext): Extension[] {
usagesLensExtension(),
autoImportExtension(),
unresolvedReferences(),
syntaxErrorsExtension(),
spellingExtension(),
];
}
+94
Переглянути файл
@@ -0,0 +1,94 @@
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { afterEach, describe, expect, it, vi } from "vitest";
const published: Array<[string, string, unknown[]]> = [];
let servers: Array<{ status: string; languages: string[] }> = [];
const serverListeners: Array<() => void> = [];
vi.mock("@/platform/ipc", () => ({
isTauri: () => true,
call: (p: Promise<unknown>) => p,
commands: { lspServers: () => Promise.resolve(servers) },
onIdeEvent: (_type: string, l: () => void) => {
serverListeners.push(l);
return () => {};
},
}));
vi.mock("@/editor/api", () => ({ whenSynced: () => Promise.resolve() }));
vi.mock("@/editor/structure", () => ({
structure: {
syntaxErrors: () =>
Promise.resolve({
errors: [
{
kind: "unexpected",
range: { start: { line: 1, character: 4 }, end: { line: 1, character: 10 } },
expected: null,
message: "unexpected `iqTask`",
},
{
kind: "missing",
range: { start: { line: 2, character: 3 }, end: { line: 2, character: 3 } },
expected: ";",
message: "missing `;`",
},
],
truncated: false,
}),
},
}));
vi.mock("./diagnostics", () => ({
setFileDiagnostics: (path: string, source: string, items: unknown[]) => published.push([path, source, items]),
}));
const { lspDoc } = await import("./lsp");
const { syntaxErrorsExtension } = await import("./syntaxErrors");
const { SYNTAX_ERRORS_PRODUCER } = await import("@/platform/inspections");
const ctx = { doc: 1, path: "/p/a.php", language: "php", uiMode: null, readOnly: false };
const views: EditorView[] = [];
function open() {
const view = new EditorView({
state: EditorState.create({
doc: "<?php\n$a = A::geUn iqTask\nb()\n",
extensions: [lspDoc.of(ctx), syntaxErrorsExtension()],
}),
});
views.push(view);
return view;
}
const settle = () => vi.advanceTimersByTimeAsync(600);
const last = () => published.filter(([, s]) => s === SYNTAX_ERRORS_PRODUCER).at(-1)?.[2];
afterEach(() => {
views.splice(0).forEach((v) => v.destroy());
published.length = 0;
vi.useRealTimers();
});
describe("syntax errors without a language server", () => {
it("reports the parser's errors when no server checks the language", async () => {
vi.useFakeTimers();
servers = [{ status: "missing", languages: ["php"] }];
open();
await settle();
expect(last()).toEqual([
expect.objectContaining({ severity: "error", message: "Unexpected token", source: "Syntax" }),
expect.objectContaining({ severity: "error", message: "';' expected" }),
]);
});
it("leaves them to a running server, and checks again when the server stops", async () => {
vi.useFakeTimers();
servers = [{ status: "ready", languages: ["php"] }];
serverListeners.forEach((l) => l());
open();
await settle();
expect(last()).toEqual([]);
servers = [{ status: "crashed", languages: ["php"] }];
serverListeners.forEach((l) => l());
await settle();
expect(last()).toHaveLength(2);
});
});
+94
Переглянути файл
@@ -0,0 +1,94 @@
/**
* Syntax errors from the built-in parser (`structure.syntaxErrors`) as diagnostics, for files
* no language server checks: none is configured for the language, or it is not installed,
* stopped or crashed. A running server reports syntax errors itself, so none are added then.
*/
import type { Extension } from "@codemirror/state";
import { EditorView, ViewPlugin } from "@codemirror/view";
import type { Diagnostic, ServerStatus, SyntaxError } from "@/bindings";
import { whenSynced } from "@/editor/api";
import { structure } from "@/editor/structure";
import { tx } from "@/platform/i18n";
import { SYNTAX_ERRORS_PRODUCER } from "@/platform/inspections";
import { call, commands, isTauri, onIdeEvent } from "@/platform/ipc";
import { setFileDiagnostics } from "./diagnostics";
import { docOf } from "./lsp";
const INSPECT_DELAY = 500;
/** Dialects the grammar doesn't follow; the database support checks SQL. */
const SKIPPED = new Set(["sql", "plaintext"]);
const RUNNING: ReadonlySet<ServerStatus> = new Set<ServerStatus>(["starting", "ready", "busy"]);
let checked: Promise<ReadonlySet<string>> | null = null;
const listeners = new Set<() => void>();
let offEvents: (() => void) | null = null;
/** Languages a running language server checks (cached until a server changes). */
function checkedLanguages(): Promise<ReadonlySet<string>> {
checked ??= call(commands.lspServers()).then(
(servers) => new Set(servers.filter((s) => RUNNING.has(s.status)).flatMap((s) => s.languages)),
() => new Set<string>(),
);
if (!offEvents) {
const changed = () => {
checked = null;
for (const l of listeners) l();
};
const off = [onIdeEvent("languageServer", changed), onIdeEvent("languageServerRemoved", changed)];
offEvents = () => off.forEach((f) => f());
}
return checked;
}
function syntaxErrorDiagnostic(e: SyntaxError): Diagnostic {
const expected = e.expected;
return {
range: e.range,
severity: "error",
message: e.kind === "missing" && expected ? tx`'${expected}' expected` : tx("Unexpected token"),
source: tx("Syntax"),
code: null,
};
}
/** Checks the file after edits (debounced) and when a language server starts or stops. */
export function syntaxErrorsExtension(): Extension {
return ViewPlugin.define((view: EditorView) => {
const doc = docOf(view);
if (!doc || doc.virtual || SKIPPED.has(doc.language) || !isTauri()) return {};
let timer: ReturnType<typeof setTimeout> | undefined;
let seq = 0;
const run = async () => {
const mine = ++seq;
try {
if ((await checkedLanguages()).has(doc.language)) {
if (mine === seq) setFileDiagnostics(doc.path, SYNTAX_ERRORS_PRODUCER, []);
return;
}
await whenSynced(view);
const { errors } = await structure.syntaxErrors(doc.doc);
if (mine !== seq) return;
setFileDiagnostics(doc.path, SYNTAX_ERRORS_PRODUCER, errors.map(syntaxErrorDiagnostic));
} catch (e) {
console.debug("[syntax errors]", e);
}
};
const schedule = () => {
clearTimeout(timer);
timer = setTimeout(() => void run(), INSPECT_DELAY);
};
schedule();
listeners.add(schedule);
return {
update: (u) => {
if (u.docChanged) schedule();
},
destroy: () => {
clearTimeout(timer);
listeners.delete(schedule);
seq++;
setFileDiagnostics(doc.path, SYNTAX_ERRORS_PRODUCER, []);
},
};
});
}