Technical deep-dive into the plugin’s internals. Linked from the Developer guide.
Design principles
- Parse each line exactly once. The JSON string of a log entry is parsed into a strongly-typed
LogEntrybyLogEntryParser. Every downstream stage (filter, format, stats, alignment width computation) consumes thatLogEntry— no re-parsing. - Pipeline, not monolith. Rendering is a composition of small stateless pieces: parser → filter chain → formatter → highlight-span builder →
EditorHighlighter. Each stage has a narrow input/output contract and can be tested in isolation. - Pure logic is Swing-free.
JsonlFormatter,JsonlRebuilder,Formatter,LogEntryParser,TargetPrefixDetector,ValueFormatter, and every filter predicate are pure Kotlin, depend only on Gson +java.time, and are unit-tested without spinning up the IntelliJ platform. - Let IntelliJ drive the paint cycle. Highlights are published as sidecar data on a
Documentand read by a customEditorHighlighter. No imperativeRangeHighlightermanagement, no per-keystroke markup-model churn. - Native integration wins over re-implementation. Colors live in the Color Scheme system (
TextAttributesKey). Per-file state lives inFileEditorState. Global settings live inPersistentStateComponent. All three interop with the IDE’s built-in export/import/sync flows.
Data flow
.jsonl file
│
▼
source String (from textEditor.document)
│
▼ source.split('\n').map { LogEntryParser.parse(it, mapping) }
List<LogEntry>
│
├──▶ allTargetsSet (for Target dropdown, before filter)
│
▼ FilterChain.accepts(entry, draft-formatted text)
kept: List<Kept(rawIdx, entry)>
│
▼ TargetPrefixDetector.commonPrefix(matchingTargets)
▼ maxLevelLen / maxTargetDisplayLen / maxMessageLen
▼ FormatConfig(prefix, pad widths, prettify, decimals)
▼ Formatter(cfg).format(entry) ——▶ FormattedLine per entry
RebuildResult(structuredLines, rawLines, mapping, stats, allTargets)
│
▼ HighlightSpanBuilder.build(structuredLines, settings)
List<HighlightSpan> (absolute-offset, continuous coverage)
│
▼ (in a single write action)
│ formattedDoc.setText(structuredLines.join("\n"))
│ formattedDoc.putUserData(JSONL_HIGHLIGHT_SPANS, spans)
│ filteredRawDoc.setText(rawLines.join("\n"))
▼
EditorHighlighter on formattedEditor picks up new spans on next paint.
Domain model
LogEntry (LogEntry.kt)
data class LogEntry(
val raw: String, // the original line, verbatim
val trimmed: String, // raw.trim()
val timestamp: Instant?, // parsed from the mapped path
val level: String?, // as read, no case folding
val target: String?, // raw value, prefix-strip at render time
val message: String?,
val fields: LinkedHashMap<String, JsonElement>, // fields object, minus message leaf
val extraTopLevel: LinkedHashMap<String, JsonElement>, // top-level keys not consumed by mapping
)
Blank lines and non-JSON lines produce a LogEntry with raw set and every other slot null/empty. isBlank / isJson helpers provide the obvious predicates.
FieldMapping (FieldMapping.kt)
Five dotted paths that tell the parser where each semantic field lives:
data class FieldMapping(
val timestampPath: String = "timestamp",
val levelPath: String = "level",
val targetPath: String = "target",
val messagePath: String = "fields.message",
val fieldsPath: String = "fields",
)
The defaults match tracing_subscriber::fmt::json. Empty path means “this semantic slot is absent”. JsonObject.lookup(path) walks the dotted segments.
FormatConfig (FormatConfig.kt)
Immutable value object that replaces an eight-argument formatLine:
data class FormatConfig(
val zone: ZoneId = ZoneId.systemDefault(),
val targetPrefix: String = "",
val levelPadWidth: Int = 0,
val targetPadWidth: Int = 0,
val messagePadWidth: Int = 0,
val prettifyValues: Boolean = false,
val timestampDecimals: Int = 6,
)
Zero pad widths disable that padding level.
FormattedLine (JsonlFormatter.kt)
What the formatter produces for one line. Carries the rendered text plus character-offset ranges for each semantic token, which the highlight-span builder translates into absolute document offsets.
data class FormattedLine(
val text: String,
val level: String?, // source level, for colour dispatch
val keyRanges: List<IntRange>, // each field-name position
val timestampRange: IntRange? = null,
val levelRange: IntRange? = null,
val targetRange: IntRange? = null,
val messageRange: IntRange? = null,
val equalsRanges: List<IntRange> = emptyList(),
)
HighlightSpan (JsonlTokenHighlighter.kt)
One contiguous run of text with its semantic key and font modifiers:
data class HighlightSpan(
val start: Int, // absolute offset in the formatted document
val end: Int,
val key: TextAttributesKey?, // null = default foreground
val bold: Boolean = false,
val italic: Boolean = false,
)
Components
Parser (LogEntryParser.kt)
Stateless. One public entry point: parse(raw: String, mapping = FieldMapping.DEFAULT). Handles malformed JSON, missing fields, unusual mappings (including empty paths and nested message paths like fields.message where the fields object is also being iterated).
Formatter (Formatter.kt)
class Formatter(private val config: FormatConfig) {
fun format(entry: LogEntry): FormattedLine
}
Builds a StringBuilder with separator-aware token emission (sep() drops a single space unless one’s already there). Computes offsets for every semantic range while it builds. Applies prettification to string values via ValueFormatter when enabled. Resolves the timestamp pattern through a ConcurrentHashMap cache keyed by decimal precision.
Value formatter (ValueFormatter.kt)
Renders one JsonElement as a key=value RHS. In prettify mode strings emit raw (no JSON re-encoding) so backslash-containing paths render as path=C:\Users\Oleg\log.jsonl instead of path="C:\\Users\\Oleg\\log.jsonl". prettify(raw) strips Rust Debug wrappers: recursive Some(...) unwrapping, then debug-quote un-escaping (\" → ", \\ → \).
Target prefix detector (TargetPrefixDetector.kt)
commonPrefix(targets) returns the longest shared segment-aligned prefix without the trailing separator. strip(target, prefix) removes the prefix plus the leading separator from individual targets, so ai_agent_dashboard_lib::usage_limits becomes usage_limits and the bare root ai_agent_dashboard_lib becomes "" (which the formatter then omits entirely unless alignment is on).
Filter chain (FilterChain.kt)
interface EntryPredicate {
fun accepts(entry: LogEntry, formatted: String): Boolean
}
class FilterChain(predicates: List<EntryPredicate>) {
val isActive: Boolean // true iff the chain has any predicate
fun accepts(entry: LogEntry, formatted: String): Boolean
}
Implementations: NonBlankPredicate, SeverityPredicate(min), TargetPredicate(exact), TextPredicate(caseInsensitiveSubstring). The text predicate takes the draft-formatted text so it can match either raw JSON or the rendered line.
Rebuilder (JsonlRebuilder.kt)
The heart of the pipeline. Stateless:
object JsonlRebuilder {
fun rebuild(source: String, session: JsonlSession, settings: JsonlSettings.State): RebuildResult
}
What it does, in order:
- Parse every line into
LogEntry. - Collect the full set of distinct targets (for the Target dropdown, independent of filters).
- Build a
FilterChainbased on session’s (severity, target, text) — if none of them is active, the chain is empty and skipped entirely. - For active filtering, draft-format each entry (with default FormatConfig) just to produce the text for
TextPredicateto match against. - Compute
commonPrefixfrom matching targets (ifstripCommonPrefix), and max level / target-display / message lengths from the kept subset. - Build the real
FormatConfig(with prefix + pad widths derived from the cascading alignment level). - Format each kept entry into a
FormattedLine, collectrawLinesand stats while at it. - Return
RebuildResult(structuredLines, rawLines, formattedToRawLine, stats, allTargets).
RebuildResult is a plain data record — no side effects, no IntelliJ types. Fully unit-testable (src/test/kotlin/com/olegs/jsonl/JsonlRebuilderTest.kt).
Token highlighter (JsonlTokenHighlighter.kt)
Custom EditorHighlighter that reads a pre-computed List<HighlightSpan> from Document.putUserData(JSONL_HIGHLIGHT_SPANS, …). The token boundaries come from the Formatter’s FormattedLine ranges (which the formatter knows precisely because it wrote the string) — no heuristic lexing.
HighlightSpanBuilder.build(lines, cfg) turns per-line ranges into absolute offsets, fills gaps with null-key spans, and resolves settings toggles (if dimTimestampAndEquals is off, no span is emitted for timestamps).
The HighlighterIterator walks the span list, resolving TextAttributesKey → TextAttributes through the current color scheme on each getTextAttributes() call, then layers Font.BOLD / Font.ITALIC as modifiers. This keeps colour-scheme switches free — the next paint just resolves differently.
Settings service (JsonlSettings.kt)
Application-level PersistentStateComponent. State layout:
data class State(
var schemaVersion: Int = 1, // for future migrations
// display toggles (7 booleans)
// layout: Alignment
// formatting: timestampDecimals: Int
// behaviour: scrollToEndOnOpen: Boolean
// field mapping (5 strings)
)
notifyChanged() publishes on JsonlSettingsListener.TOPIC; every open JsonlEditor subscribes and rebuilds on change.
Configurable (JsonlConfigurable.kt)
Kotlin UI DSL v2 form under Settings → Tools → JSONL Log Viewer. Groups: Formatted view (7 checkboxes + alignment combo + decimals int field), Behaviour (1 checkbox), Field mapping (5 text fields), Colours (pointer to Color Scheme page).
Color scheme integration (JsonlColors.kt + JsonlColorSettingsPage.kt)
Ten semantic TextAttributesKeys, each with a palette-friendly default (DefaultLanguageHighlighterColors.*). ColorSettingsPage registers them under a dedicated JSONL Log Viewer page with a demo text block, so users customize colours through the same flow they use for Java / Kotlin / JSON syntax colours.
Per-file state (JsonlFileEditorState.kt + JsonlSplitEditorProvider.kt)
Six fields — leftPane, rightPane, minSeverity, targetFilter, textFilter, timeDisplay — persisted as attributes on the project workspace XML element:
<provider editor-type-id="jsonl-split-editor">
<state leftPane="FORMATTED" rightPane="INSPECT" minSeverity="WARN"
timeDisplay="ABSOLUTE" textFilter="chat_id" />
</provider>
JsonlSplitEditorProvider.writeState / readState do the serialization. JsonlEditor.getState / setState apply incoming state on editor open (including triggering a filter rebuild with the saved filter values).
The editor (JsonlEditor.kt)
The FileEditor shell. Owns:
- Three synthetic editors —
formattedEditor(plain-text viewer),filteredRawEditor(plain-text viewer, always shown for Raw pane),inspectEditor(JSON editor, viewer mode). - The real
textEditor— kept alive for document event listening + IDE file-system integration but never shown in the UI. - The toolbar
DefaultActionGroupwith gear icon, pane pickers, filters, stats labels, and clickable time-display labels. - One
Alarmfor debouncing filter input (200 ms) and another for debouncing document change rebuilds (100 ms). - A third alarm ticking every 30 s to refresh the “most recent” relative time so it walks forward without a document edit.
Caret listeners on all three viewer editors keep Inspect pane in sync with the driving pane (formatted pane if visible, else filtered-raw).
Settings persistence matrix
| Key | Scope | Storage |
|---|---|---|
| Display toggles, alignment, timestamp decimals, field mapping | Global | PersistentStateComponent → JsonlLogViewerSettings.xml |
| Colors | Per-scheme | IntelliJ Color Scheme system |
| Pane selection, filters, time display | Per-file | FileEditorState → project workspace XML |
| Splitter proportion | Global (by key) | PropertiesComponent under JsonlEditor.splitter.proportion |
| Default pane / time display for first-open files | Global | PropertiesComponent under com.olegs.jsonl.{leftPane|rightPane|timeDisplay} |
Live-update path
Any setting change fires JsonlSettings.notifyChanged() → JsonlSettingsListener.TOPIC.syncPublisher.settingsChanged(). Every JsonlEditor subscribes in init (with this as the parent Disposable, so the subscription unregisters automatically on editor close). Listener calls applyFiltersNow() → rebuildFromSource() → full pipeline → document update + span publication → paint.
Color scheme changes bypass the rebuild entirely — EditorHighlighter.setColorScheme(scheme) is called by the platform, and the next HighlighterIterator.getTextAttributes() resolves through the new scheme.
Why the plugin only needs com.intellij.modules.platform
Nothing in the plugin references language-specific APIs. JSON syntax highlighting in the Inspect pane resolves at runtime via FileTypeManager.getInstance().getFileTypeByExtension("json") — if JSON is not registered (obscure IDE), Inspect falls back to plain text. This keeps the plugin compatible with every IntelliJ-based IDE: IntelliJ IDEA (Community + Ultimate), PyCharm, WebStorm, Rider, GoLand, CLion, DataGrip, RubyMine, RustRover, PhpStorm.