From 2ba07341c20d2c07414cdf290043fc2e1c5c74bb Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:02:32 +0800 Subject: [PATCH] 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 --- src/editor/lsp/index.ts | 2 + src/editor/lsp/syntaxErrors.test.ts | 94 +++++++++++++++++++++++++++++ src/editor/lsp/syntaxErrors.ts | 94 +++++++++++++++++++++++++++++ src/platform/inspections.ts | 12 ++++ src/platform/locales/text/zh.json | 7 ++- 5 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 src/editor/lsp/syntaxErrors.test.ts create mode 100644 src/editor/lsp/syntaxErrors.ts diff --git a/src/editor/lsp/index.ts b/src/editor/lsp/index.ts index fd9e5f50..0b8cc1f1 100644 --- a/src/editor/lsp/index.ts +++ b/src/editor/lsp/index.ts @@ -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(), ]; } diff --git a/src/editor/lsp/syntaxErrors.test.ts b/src/editor/lsp/syntaxErrors.test.ts new file mode 100644 index 00000000..250758ec --- /dev/null +++ b/src/editor/lsp/syntaxErrors.test.ts @@ -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) => 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: " 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); + }); +}); diff --git a/src/editor/lsp/syntaxErrors.ts b/src/editor/lsp/syntaxErrors.ts new file mode 100644 index 00000000..6f951da5 --- /dev/null +++ b/src/editor/lsp/syntaxErrors.ts @@ -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 = new Set(["starting", "ready", "busy"]); + +let checked: Promise> | 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> { + checked ??= call(commands.lspServers()).then( + (servers) => new Set(servers.filter((s) => RUNNING.has(s.status)).flatMap((s) => s.languages)), + () => new Set(), + ); + 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 | 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, []); + }, + }; + }); +} diff --git a/src/platform/inspections.ts b/src/platform/inspections.ts index 0e860c73..d2269c93 100644 --- a/src/platform/inspections.ts +++ b/src/platform/inspections.ts @@ -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", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 9b97ba2f..dda23592 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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.": "报告内置解析器无法解析的代码,仅针对没有语言服务器检查的文件(该语言未配置语言服务器,或未安装、已停止、已崩溃)。语言服务器运行时由它自己报告语法错误。" }