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
This commit is contained in:
l0ng-ai
2026-11-04 03:02:32 +08:00
committed by GitHub
parent 0dffad814a
commit 2ba07341c2
5 changed files with 208 additions and 1 deletions
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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, []);
},
};
});
}
+12
View File
@@ -223,6 +223,9 @@ export interface BuiltinInspection {
export const SPELLING_PRODUCER = "spelling";
export const SPELLING_INSPECTION = "builtin:spelling";
/** Syntax errors of the built-in parser, for files no language server checks. */
export const SYNTAX_ERRORS_PRODUCER = "syntax";
export const DEPENDENCY_PRODUCER = "dependency-versions";
export const DEPENDENCY_INSPECTION = "builtin:dependency-versions";
@@ -256,6 +259,15 @@ export const BUILTIN_INSPECTIONS: readonly BuiltinInspection[] = [
description: "Reports newer crate versions that Cargo.toml requirements do not already allow.",
defaultLevel: "weakWarning",
},
{
id: "builtin:syntax",
producer: SYNTAX_ERRORS_PRODUCER,
name: "Syntax errors",
group: "General",
description:
"Reports code the built-in parser cannot read, in files no language server checks (none is configured for the language, or it is not installed, stopped or crashed). A running language server reports syntax errors itself.",
defaultLevel: "error",
},
{
id: "builtin:file-references",
producer: "file-references",
+6 -1
View File
@@ -5779,5 +5779,10 @@
"File icon theme": "文件图标主题",
"Plugin not enabled": "插件未启用",
"Problems the {plugin} plugin reports. Severity \"As reported\" keeps the level the plugin assigns; a code below can override it.": "{plugin} 插件报告的问题。严重性“按报告”保留插件分配的级别;下方的代码可以覆盖它。",
"The plugin system is a preview: its API and the plugins may still change.": "插件系统目前是预览版:API 和插件都可能还会变化。"
"The plugin system is a preview: its API and the plugins may still change.": "插件系统目前是预览版:API 和插件都可能还会变化。",
"'{expected}' expected": "缺少 '{expected}'",
"Unexpected token": "意外的标记",
"Syntax": "语法",
"Syntax errors": "语法错误",
"Reports code the built-in parser cannot read, in files no language server checks (none is configured for the language, or it is not installed, stopped or crashed). A running language server reports syntax errors itself.": "报告内置解析器无法解析的代码,仅针对没有语言服务器检查的文件(该语言未配置语言服务器,或未安装、已停止、已崩溃)。语言服务器运行时由它自己报告语法错误。"
}