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], <Info>, level=error), timestamps, exception names, URLs and stack frames, reusing scheme colours; WARN uses the UI warning colour.

Closes #1541

Co-authored-by: jaw52 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
JawandClaude Opus 5.5 authored and GitHub committed 2026-11-05 23:37:26 +08:00
1 parent 1186cdce64
commit 4ca44ee815
6 files changed
+135 -1

No files matched your search

+1
View File
@@ -533,6 +533,7 @@ mod tests {
"Julia",
"Kotlin",
"LiveScript",
"Log",
"Lua",
"mIRC",
"Mathematica",
+1
View File
@@ -2,3 +2,4 @@ id = "log"
name = "Log"
extensions = ["log"]
indent = { tab-size = 4, insert-spaces = true }
ui-mode = "Log"
+5 -1
View File
@@ -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)" },
+40
View File
@@ -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 = [
+5
View File
@@ -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 () => {
+83
View File
@@ -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]`, `<Info>`, `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<State> = {
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);