mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 10:16:03 -10:00
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:
1 parent
1186cdce64
commit
4ca44ee815
6 files changed
+135
-1
No files matched your search
@@ -533,6 +533,7 @@ mod tests {
|
||||
"Julia",
|
||||
"Kotlin",
|
||||
"LiveScript",
|
||||
"Log",
|
||||
"Lua",
|
||||
"mIRC",
|
||||
"Mathematica",
|
||||
|
||||
@@ -2,3 +2,4 @@ id = "log"
|
||||
name = "Log"
|
||||
extensions = ["log"]
|
||||
indent = { tab-size = 4, insert-spaces = true }
|
||||
ui-mode = "Log"
|
||||
@@ -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)" },
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user