From 4ca44ee815344fc1324fe82a4cd2cbb6e92894d9 Mon Sep 17 00:00:00 2001 From: Jaw <53564781+jaw52@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:37:26 +0800 Subject: [PATCH] feat(editor): highlight log files by level, timestamp and stack frame (#1577) .log files rendered as plain text: the log language had no ui-mode and CodeMirror ships no log grammar. A lightweight Log stream mode now colours severity levels (upper case anywhere, any case only when marked: [warn], , level=error), timestamps, exception names, URLs and stack frames, reusing scheme colours; WARN uses the UI warning colour. Closes #1541 Co-authored-by: jaw52 <53564781+jaw52@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/ide-language/src/registry.rs | 1 + languages/log.toml | 1 + src/editor/core/cm/highlight.ts | 6 +- src/editor/core/cm/languages.test.ts | 40 ++++++++++++++ src/editor/core/cm/languages.ts | 5 ++ src/editor/core/cm/log.ts | 83 ++++++++++++++++++++++++++++ 6 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 src/editor/core/cm/log.ts diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index aff9d7de2..1c314bca4 100644 --- a/crates/ide-language/src/registry.rs +++ b/crates/ide-language/src/registry.rs @@ -533,6 +533,7 @@ mod tests { "Julia", "Kotlin", "LiveScript", + "Log", "Lua", "mIRC", "Mathematica", diff --git a/languages/log.toml b/languages/log.toml index 5c2f58821..9fa0481e8 100644 --- a/languages/log.toml +++ b/languages/log.toml @@ -2,3 +2,4 @@ id = "log" name = "Log" extensions = ["log"] indent = { tab-size = 4, insert-spaces = true } +ui-mode = "Log" diff --git a/src/editor/core/cm/highlight.ts b/src/editor/core/cm/highlight.ts index 4518a0874..81be365ae 100644 --- a/src/editor/core/cm/highlight.ts +++ b/src/editor/core/cm/highlight.ts @@ -4,7 +4,10 @@ * -effect`, set by Settings › Editor › Color Scheme overrides. */ import { HighlightStyle, type TagStyle } from "@codemirror/language"; -import { tags as t, type Tag } from "@lezer/highlight"; +import { Tag, tags as t } from "@lezer/highlight"; + +/** Log warnings: the UI warning colour, since no syntax token reads as one in every scheme. */ +export const logWarning = Tag.define("logWarning"); function token( tag: Tag | Tag[], @@ -86,6 +89,7 @@ export const openideaHighlightStyle = HighlightStyle.define([ token(t.url, "function", { italic: "italic" }), token(t.monospace, "markup-code"), token(t.contentSeparator, "keyword"), + { tag: logWarning, color: "var(--warning)" }, { tag: t.inserted, color: "var(--vcs-added)" }, { tag: t.deleted, color: "var(--danger)" }, { tag: t.changed, color: "var(--vcs-modified)" }, diff --git a/src/editor/core/cm/languages.test.ts b/src/editor/core/cm/languages.test.ts index 5ca460d6f..4dd8ccc41 100644 --- a/src/editor/core/cm/languages.test.ts +++ b/src/editor/core/cm/languages.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { highlightTree, tagHighlighter, tags as t } from "@lezer/highlight"; +import { logWarning } from "./highlight"; import { findLanguageDescription, loadSupport } from "./languages"; describe("language modes", () => { @@ -57,6 +58,45 @@ describe("language modes", () => { } }); + it("highlights log levels, timestamps, exception names and stack frames", async () => { + const description = findLanguageDescription("Log", "/var/log/server.log"); + expect(description?.name).toBe("Log"); + const support = await loadSupport(description!); + const doc = [ + "2024-05-01 12:00:00,123 ERROR [main] Boom: java.lang.IllegalStateException: bad", + "\tat com.acme.Main.run(Main.java:42)", + "\t... 3 more", + "12:00:01.5 [warn] retry https://acme.test/x", + "ts=1 level=info msg=started", + "INFOS and errors stay plain, DEBUG does not", + ].join("\n"); + const highlighter = tagHighlighter([ + { tag: t.invalid, class: "error" }, + { tag: logWarning, class: "warn" }, + { tag: t.string, class: "info" }, + { tag: t.comment, class: "dim" }, + { tag: t.number, class: "time" }, + { tag: t.typeName, class: "type" }, + { tag: t.link, class: "link" }, + ]); + const highlighted: [string, string][] = []; + highlightTree(support!.language.parser.parse(doc), highlighter, (from, to, style) => { + highlighted.push([doc.slice(from, to), style]); + }); + expect(highlighted).toEqual([ + ["2024-05-01 12:00:00,123", "time"], + ["ERROR", "error"], + ["java.lang.IllegalStateException", "type"], + ["\tat com.acme.Main.run(Main.java:42)", "dim"], + ["\t... 3 more", "dim"], + ["12:00:01.5", "time"], + ["warn", "warn"], + ["https://acme.test/x", "link"], + ["info", "info"], + ["DEBUG", "dim"], + ]); + }); + it("assigns highlight tags to AsciiDoc syntax", async () => { const support = await loadSupport(findLanguageDescription("AsciiDoc", "/docs/guide.adoc")!); const doc = [ diff --git a/src/editor/core/cm/languages.ts b/src/editor/core/cm/languages.ts index 5bf648488..648f89a51 100644 --- a/src/editor/core/cm/languages.ts +++ b/src/editor/core/cm/languages.ts @@ -85,6 +85,11 @@ const OWN_MODES = [ extensions: ["adoc", "asciidoc"], load: () => import("./asciidoc").then((m) => new LanguageSupport(m.asciidocLanguage)), }), + LanguageDescription.of({ + name: "Log", + extensions: ["log"], + load: () => import("./log").then((m) => new LanguageSupport(m.logLanguage)), + }), LanguageDescription.of({ name: "Shell", load: async () => { diff --git a/src/editor/core/cm/log.ts b/src/editor/core/cm/log.ts new file mode 100644 index 000000000..aed05826c --- /dev/null +++ b/src/editor/core/cm/log.ts @@ -0,0 +1,83 @@ +import { StreamLanguage, type StreamParser } from "@codemirror/language"; +import { logWarning } from "./highlight"; + +const LEVELS = + "FATAL|CRITICAL|SEVERE|EMERG|ALERT|ERROR|ERR|WARNING|WARN|NOTICE|INFO|DEBUG|TRACE|VERBOSE|FINEST|FINER|FINE"; +// Upper case anywhere; any case only where a level is unmistakable: `[warn]`, ``, `level=error`. +const LEVEL_UPPER = new RegExp(`^(?:${LEVELS})\\b`); +const LEVEL_MARKED = new RegExp(`^(?:${LEVELS})(?=[\\]>])`, "i"); +const LEVEL_KEY = /^(?:level|lvl|severity)=/i; +const LEVEL_VALUE = new RegExp(`^(?:${LEVELS})\\b`, "i"); + +const TIMESTAMP = [ + /^\d{4}[-/]\d\d[-/]\d\d(?:[T ]\d\d:\d\d(?::\d\d(?:[.,]\d+)?)?(?:Z|[+-]\d\d(?::?\d\d)?)?)?/, + /^\d\d:\d\d:\d\d(?:[.,]\d+)?/, + /^(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) +\d{1,2} \d\d:\d\d:\d\d/, +]; + +const EXCEPTION = /^[\w$]+(?:\.[\w$]+)*(?:Exception|Error)\b/; +const FRAME = /^\s+(?:at\s|\.\.\. \d+ (?:more|common frames omitted))/; + +type State = { keyLevel: boolean }; + +/** Level word → the highlight tag whose colour carries its severity in every scheme. */ +function levelToken(word: string): string { + switch (word.toUpperCase()) { + case "FATAL": + case "CRITICAL": + case "SEVERE": + case "EMERG": + case "ALERT": + case "ERROR": + case "ERR": + return "invalid"; + case "WARN": + case "WARNING": + case "NOTICE": + return "logWarning"; + case "INFO": + return "string"; + default: + return "comment"; + } +} + +const parser: StreamParser = { + name: "log", + tokenTable: { logWarning }, + startState: () => ({ keyLevel: false }), + copyState: ({ keyLevel }) => ({ keyLevel }), + token(stream, state) { + if (stream.sol() && FRAME.test(stream.string)) { + stream.skipToEnd(); + return "comment"; + } + if (stream.eatSpace()) return null; + if (state.keyLevel) { + state.keyLevel = false; + const m = stream.match(LEVEL_VALUE) as RegExpMatchArray | null; + if (m) return levelToken(m[0]); + } + if (stream.match(LEVEL_KEY)) { + state.keyLevel = true; + return "propertyName"; + } + const before = stream.start === 0 ? "" : stream.string[stream.start - 1]!; + const wordStart = !/[\w$.-]/.test(before); + if (wordStart) { + for (const re of TIMESTAMP) if (stream.match(re)) return "number"; + const level = (stream.match(LEVEL_UPPER) || (/[[<]/.test(before) && stream.match(LEVEL_MARKED))) as + | RegExpMatchArray + | boolean + | null; + if (level) return levelToken(stream.current()); + if (stream.match(EXCEPTION)) return "typeName"; + if (stream.match(/^https?:\/\/[^\s"'<>)\]]+/)) return "link"; + } + if (stream.match(/^[\w$]+/)) return null; + stream.next(); + return null; + }, +}; + +export const logLanguage = StreamLanguage.define(parser);