crates/fresh-editor/docs/design-inline-diagnostics.md
Display LSP diagnostic messages inline at the end of the affected line, rendered
after the source code with a severity-colored background. This is similar to
Neovim's virtual_text diagnostics or VS Code's "Error Lens" extension.
4 │ fn main() {
5 │ let x: i32 = "hello"; ● expected `i32`, found `&str`
6 │ unused_var(); ▲ unused variable: `unused_var`
7 │ }
The diagnostic text appears to the right of the source code, separated by a gap, with background/foreground colors matching the diagnostic severity (red for errors, yellow for warnings, blue for info, gray for hints).
DiagnosticColors from the themeEndOfLine positionAdd a new VirtualTextPosition::EndOfLine variant. Diagnostic messages are
stored as virtual texts anchored to each diagnostic line.
Rejected because it creates one VirtualText + marker per diagnostic across the entire buffer. In a huge file with thousands of diagnostics, this means:
build_lookup() per frameNew purpose-built manager with markers per diagnostic.
Rejected for the same marker-bloat reasons as Alternative A, plus it duplicates infrastructure already provided by the overlay system.
Leverage the existing diagnostic overlay system. Overlays already carry
marker-tracked positions, viewport-efficient queries (O(log M + k)), severity
(encoded in priority), and the diagnostic message (overlay.message). The
renderer already filters overlays to the viewport and identifies diagnostic
lines. Inline text is derived from this data at render time with zero
additional persistent state or markers.
Use MarginManager's right margin — fixed-width for ALL lines, wasting space. Rejected.
Full line below the affected source line. Rejected — takes vertical space, disrupts reading flow, doesn't match the design spec.
The gutter diagnostic indicator (●) is already rendered by piggy-backing on
overlay data. The render pipeline:
overlay_manager.query_viewport() — O(log M + k) efficientnamespace == "lsp-diagnostic"range.start to a line_start_bytediagnostic_lines: HashSet<usize>diagnostic_lines.contains(&line_start_byte)Inline diagnostic text uses the same mechanism, but additionally reads the
overlay's .message field and .priority for dedup/severity.
LSP publishDiagnostics
→ apply_diagnostics_to_state_cached() [existing, unchanged]
→ creates Overlay per diagnostic with:
- byte range (marker-tracked)
- namespace "lsp-diagnostic"
- priority (100=error, 50=warning, 30=info, 10=hint)
- message (diagnostic text) ← already set
→ render loop
→ query_viewport() filters overlays [existing]
→ build diagnostic_inline_texts from viewport overlays [NEW]
→ per-line: render inline text if present [NEW]
Zero new markers. Zero new data structures persisted on EditorState. Zero changes to diagnostics.rs.
In DecorationContext::build(), alongside the existing diagnostic_lines
HashSet, build a map of inline texts from the same viewport overlays:
// Existing: identifies which lines have diagnostics (for gutter ●)
let diagnostic_lines: HashSet<usize> = ...; // unchanged
// NEW: per-line inline diagnostic text, deduped to highest severity
let diagnostic_inline_texts: HashMap<usize, (&str, Style)> =
build_inline_diagnostic_texts(&viewport_overlays, &diagnostic_ns, ...);
The build_inline_diagnostic_texts function:
namespace == "lsp-diagnostic"range.start → line_start_byteHashMap<line_start_byte, (message, style)>This is O(k) where k = diagnostic overlays in the viewport — typically < 50.
After the character loop for a line completes, before end-of-line fill:
if let Some((message, style)) = diagnostic_inline_texts.get(&line_start_byte) {
let used_columns = current_visual_col;
let available = viewport_width.saturating_sub(used_columns);
let gap = 2; // spaces between code and diagnostic
let min_width = 10; // don't show if less than this available
if available > gap + min_width {
// Render gap
push_span(" ", Style::default());
// Truncate and render message
let max_chars = available - gap;
let display = truncate_to_width(message, max_chars);
push_span(&display, *style);
}
}
Derived from the overlay's priority field (already set by diagnostic_to_overlay):
fn inline_style_from_priority(priority: i32, theme: &Theme) -> Style {
match priority {
100 => Style::default().fg(theme.diagnostic_error_fg).bg(theme.diagnostic_error_bg),
50 => Style::default().fg(theme.diagnostic_warning_fg).bg(theme.diagnostic_warning_bg),
30 => Style::default().fg(theme.diagnostic_info_fg).bg(theme.diagnostic_info_bg),
_ => Style::default().fg(theme.diagnostic_hint_fg).bg(theme.diagnostic_hint_bg),
}
}
Add to the editor configuration schema:
{
"diagnostics": {
"inline_text": {
"enabled": false
}
}
}
enabled: Master toggle (default: false for initial release)Keep configuration minimal for the initial implementation.
Line wrapping: Inline diagnostic appears at the end of the last visual line of a wrapped source line. If the line wraps such that no space remains on the last visual line, the diagnostic is not shown.
Code folding: Folded lines don't render, so their inline diagnostics are naturally hidden. The fold header line may have its own diagnostic.
Horizontal scrolling: If the line content extends past the viewport, the diagnostic is not shown (no remaining space).
Existing diagnostic overlays: Unchanged. The underline/background overlays on the diagnostic range continue to work. The inline text is additive.
Following CONTRIBUTING.md E2E testing requirements:
E2E test: inline diagnostic display
E2E test: highest severity wins
E2E test: truncation
E2E test: toggle
inline_diagnostic_texts to DecorationContext (derived from
viewport overlays at build time)