Skip to main content

logicaffeine_cli/
diagnostics.rs

1//! Every diagnostic `largo` shows, placed in the author's own file.
2//!
3//! A compile error, an LGX finding and a `--json` diagnostic all start as a span on whatever
4//! source the compiler was handed — usually an ASSEMBLED closure, with every imported module
5//! spliced in ahead of the file's own text. [`Placed`] maps that span back through the closure's
6//! `SourceMap` to the file it was written in, once, and every surface renders from the result: the
7//! terminal block ([`render_parse_error`], [`render_finding`]), the `logos-diagnostic/1` object
8//! ([`parse_error_diagnostic`], [`finding_diagnostic`]) and the dev server's overlay
9//! ([`overlay`]). None of them derives a location of its own, so `largo check`, `largo check
10//! --json`, `largo serve` and `largo test` point at the same `file:line:col`.
11//!
12//! The envelopes (`logos-check/1`, `logos-fmt/1`, `logos-run/1`) print to stdout; the prose keeps
13//! flowing to stderr so humans lose nothing.
14
15use std::path::{Path, PathBuf};
16
17use logicaffeine_compile::analysis::SourceMap;
18use logicaffeine_language::diagnostic::{diagnostic_for, line_col, DiagSpan, Diagnostic};
19use logicaffeine_language::error::ParseErrorKind;
20use logicaffeine_language::lgx::check::{Finding, Severity};
21use logicaffeine_language::lint::{Finding as CopyFinding, Severity as CopySeverity};
22use logicaffeine_language::token::Span;
23use logicaffeine_language::{socratic_explanation, Interner, ParseError};
24
25use crate::ui::CliError;
26
27/// A span in the author's own file: the file, its text, and the byte range within it.
28#[derive(Debug, Clone)]
29pub(crate) struct Placed {
30    /// The file the span is in.
31    pub file: PathBuf,
32    /// That file's text — the excerpt under a caret is drawn from it.
33    pub text: String,
34    /// The byte range, in `text`'s own coordinates.
35    pub span: Span,
36}
37
38impl Placed {
39    /// A span already in `file`'s coordinates.
40    pub(crate) fn in_file(file: &Path, text: String, span: Span) -> Placed {
41        let span = clamp(span, &text);
42        Placed {
43            file: file.to_path_buf(),
44            text,
45            span,
46        }
47    }
48
49    /// A span on an assembled closure, placed in the file it was written in.
50    ///
51    /// An EXACT segment (the entry's own text, byte-intact) maps the whole span — file, line,
52    /// column, and a caret the width of the mistake. A TRANSFORMED segment (an imported module:
53    /// note-block / privacy stripping, owner-mangling) is not byte-intact, so a byte offset
54    /// within it does not carry over — but its LINE does: newlines are conserved by every
55    /// transform, so counting them from the segment's own start in `assembled` and landing on
56    /// that line in the module's own text names the real file at its real line, with a
57    /// single-character caret at the line's start standing in for the column a transform can no
58    /// longer promise. Only when the segment cannot be found in `map` at all does this fall back
59    /// to the assembled text under `fallback`, the file the closure was assembled from.
60    pub(crate) fn locate(span: Span, assembled: &str, map: &SourceMap, fallback: &Path) -> Placed {
61        if let Some(loc) = map.locate(span.start) {
62            if let Ok(text) = std::fs::read_to_string(loc.file) {
63                if loc.exact {
64                    let width = span.end.saturating_sub(span.start);
65                    let start = loc.user_offset;
66                    return Placed::in_file(loc.file, text, Span::new(start, start + width));
67                }
68                // A module's segment starts at its own first `##` section (the entry-assembly
69                // splice drops the markdown title + leading prose before it, see
70                // `logicaffeine_compile::loader::module_code`), so the newlines within that prose
71                // are missing from `assembled` — added back here to land on the module's OWN line.
72                let dropped = newline_count(&text, header_start(&text));
73                let line = dropped
74                    + newline_count(assembled, span.start)
75                        .saturating_sub(newline_count(assembled, loc.seg_start));
76                let start = nth_line_start(&text, line);
77                return Placed::in_file(loc.file, text, Span::new(start, start));
78            }
79        }
80        Placed::in_file(fallback, assembled.to_string(), span)
81    }
82
83    /// The 1-based line and column of the span's start.
84    pub(crate) fn line_col(&self) -> (usize, usize) {
85        line_col(&self.text, self.span.start)
86    }
87
88    /// `file:line:col` — the form every editor and terminal opens, the file relative to the directory
89    /// `largo` runs in when it is under it (as rustc names a file), so the same mistake in the same project
90    /// reads the same wherever the project is.
91    pub(crate) fn pointer(&self) -> String {
92        let (line, col) = self.line_col();
93        format!("{}:{line}:{col}", shown_path(&self.file).display())
94    }
95
96    /// The span as a `logos-diagnostic/1` span.
97    fn diag_span(&self) -> DiagSpan {
98        let (start_line, start_col) = line_col(&self.text, self.span.start);
99        let (end_line, end_col) = line_col(&self.text, self.span.end);
100        DiagSpan {
101            start: self.span.start,
102            end: self.span.end,
103            start_line,
104            start_col,
105            end_line,
106            end_col,
107        }
108    }
109}
110
111/// `file` relative to the current directory when it is under it, else as it is.
112fn shown_path(file: &Path) -> PathBuf {
113    std::env::current_dir()
114        .ok()
115        .and_then(|here| file.strip_prefix(&here).ok().map(Path::to_path_buf))
116        .unwrap_or_else(|| file.to_path_buf())
117}
118
119/// Keep a span inside its text and on character boundaries, so no reporter can panic on it.
120fn clamp(span: Span, text: &str) -> Span {
121    let floor = |mut at: usize| {
122        at = at.min(text.len());
123        while at > 0 && !text.is_char_boundary(at) {
124            at -= 1;
125        }
126        at
127    };
128    let start = floor(span.start);
129    let end = floor(span.end.max(span.start)).max(start);
130    Span::new(start, end)
131}
132
133/// The number of newlines in `text` before byte `at` — a transform-conserved position
134/// ([`Placed::locate`]'s non-exact case), since splicing, stripping and mangling all keep a
135/// module's own line structure even when they change its bytes.
136fn newline_count(text: &str, at: usize) -> usize {
137    let at = at.min(text.len());
138    text.as_bytes()[..at]
139        .iter()
140        .filter(|&&b| b == b'\n')
141        .count()
142}
143
144/// The byte offset where 0-based line `n` of `text` starts, clamped to the text's own end when
145/// `n` runs past its last line.
146fn nth_line_start(text: &str, n: usize) -> usize {
147    if n == 0 {
148        return 0;
149    }
150    text.match_indices('\n')
151        .nth(n - 1)
152        .map(|(i, _)| i + 1)
153        .unwrap_or(text.len())
154}
155
156/// Where a module's OWN CODE starts, mirroring `logicaffeine_compile::loader::module_code`: the
157/// first `## ` section, dropping the markdown title and any leading prose ahead of it (empty when
158/// `text` opens with `## ` itself).
159fn header_start(text: &str) -> usize {
160    text.find("\n## ").map(|i| i + 1).unwrap_or(0)
161}
162
163/// The error with its span moved into the placed file.
164fn placed_error(pe: &ParseError, at: &Placed) -> ParseError {
165    ParseError {
166        kind: pe.kind.clone(),
167        span: at.span,
168    }
169}
170
171/// The caret excerpt of `at`, without `display_with_source`'s own `error:` headline.
172fn excerpt(pe: &ParseError, at: &Placed) -> String {
173    placed_error(pe, at)
174        .display_with_source(&at.text)
175        .lines()
176        .skip(1)
177        .collect::<Vec<_>>()
178        .join("\n")
179}
180
181/// A compile error as the terminal shows it: the Socratic explanation, the caret excerpt, and the
182/// `--> file:line:col` an editor opens.
183pub(crate) fn render_parse_error(pe: &ParseError, at: &Placed) -> String {
184    let explanation = socratic_explanation(&placed_error(pe, at), &Interner::new());
185    format!("{explanation}\n{}\n  --> {}", excerpt(pe, at), at.pointer())
186}
187
188/// A compile error as a `logos-diagnostic/1` object naming its file.
189pub(crate) fn parse_error_diagnostic(pe: &ParseError, at: &Placed) -> Diagnostic {
190    let file = at.file.display().to_string();
191    diagnostic_for(
192        &placed_error(pe, at),
193        &at.text,
194        Some(&file),
195        &Interner::new(),
196    )
197}
198
199/// The word a finding is reported under.
200pub(crate) fn finding_severity(f: &Finding) -> &'static str {
201    match f.severity {
202        Severity::Error => "error",
203        Severity::Warning => "warning",
204    }
205}
206
207/// A finding's stable code: its kind's name in kebab case (`ImageWithoutAlt` →
208/// `image-without-alt`), so a new kind is reported under its own name the moment it exists.
209pub(crate) fn finding_code(f: &Finding) -> String {
210    let debug = format!("{:?}", f.kind);
211    let name = debug
212        .split(|c: char| c == '(' || c == ' ' || c == '{')
213        .next()
214        .unwrap_or(&debug);
215    let mut code = String::with_capacity(name.len() + 4);
216    for (i, c) in name.chars().enumerate() {
217        if c.is_ascii_uppercase() {
218            if i > 0 {
219                code.push('-');
220            }
221            code.push(c.to_ascii_lowercase());
222        } else {
223            code.push(c);
224        }
225    }
226    code
227}
228
229/// An LGX finding as the terminal shows it — its message, the markup it is about, and where.
230pub(crate) fn render_finding(f: &Finding, at: &Placed) -> String {
231    let carrier = ParseError {
232        kind: ParseErrorKind::Custom(f.message.clone()),
233        span: f.span,
234    };
235    format!(
236        "{}\n{}\n  --> {}",
237        f.message,
238        excerpt(&carrier, at),
239        at.pointer()
240    )
241}
242
243/// An LGX finding as a `logos-diagnostic/1` object naming its file.
244pub(crate) fn finding_diagnostic(f: &Finding, at: &Placed) -> Diagnostic {
245    Diagnostic {
246        code: finding_code(f),
247        severity: finding_severity(f).to_string(),
248        file: Some(at.file.display().to_string()),
249        span: at.diag_span(),
250        message: f.message.clone(),
251        explanation: String::new(),
252        suggestion: None,
253        quickfix: None,
254        docs: None,
255        related: Vec::new(),
256    }
257}
258
259/// The word a value-semantics copy warning is reported under — mirrors `finding_severity`'s own
260/// convention for its own, separate `Finding` type.
261pub(crate) fn copy_finding_severity(f: &CopyFinding) -> &'static str {
262    match f.severity {
263        CopySeverity::Warning => "warning",
264    }
265}
266
267/// A copy-lint finding's stable code: its kind's name in kebab case, the same convention
268/// `finding_code` uses (a new kind is reported under its own name the moment it exists).
269pub(crate) fn copy_finding_code(f: &CopyFinding) -> String {
270    let debug = format!("{:?}", f.kind);
271    let name = debug
272        .split(|c: char| c == '(' || c == ' ' || c == '{')
273        .next()
274        .unwrap_or(&debug);
275    let mut code = String::with_capacity(name.len() + 4);
276    for (i, c) in name.chars().enumerate() {
277        if c.is_ascii_uppercase() {
278            if i > 0 {
279                code.push('-');
280            }
281            code.push(c.to_ascii_lowercase());
282        } else {
283            code.push(c);
284        }
285    }
286    code
287}
288
289/// A value-semantics copy warning as the terminal shows it — its message, the mutation it is
290/// about, and where.
291pub(crate) fn render_copy_finding(f: &CopyFinding, at: &Placed) -> String {
292    let carrier = ParseError {
293        kind: ParseErrorKind::Custom(f.message.clone()),
294        span: f.span,
295    };
296    format!(
297        "{}\n{}\n  --> {}",
298        f.message,
299        excerpt(&carrier, at),
300        at.pointer()
301    )
302}
303
304/// A value-semantics copy warning as a `logos-diagnostic/1` object naming its file.
305pub(crate) fn copy_finding_diagnostic(f: &CopyFinding, at: &Placed) -> Diagnostic {
306    Diagnostic {
307        code: copy_finding_code(f),
308        severity: copy_finding_severity(f).to_string(),
309        file: Some(at.file.display().to_string()),
310        span: at.diag_span(),
311        message: f.message.clone(),
312        explanation: String::new(),
313        suggestion: None,
314        quickfix: None,
315        docs: None,
316        related: Vec::new(),
317    }
318}
319
320/// The dev server's error overlay for a compile error, drawn against the file it is in and naming
321/// it. `reload` is the live-reload client the overlay carries so a fix snaps back.
322pub(crate) fn overlay(pe: &ParseError, at: &Placed, reload: &str) -> String {
323    let name = at
324        .file
325        .file_name()
326        .map(|f| f.to_string_lossy().into_owned());
327    logicaffeine_compile::web_host::error_overlay_html_in(
328        &placed_error(pe, at),
329        &at.text,
330        reload,
331        name.as_deref(),
332    )
333}
334
335/// Why an entry program failed to become one, rendered — the same block `largo check` prints.
336///
337/// For the callers (`largo serve`, `largo build`) that must know what a project IS before they
338/// can act, and so parse its entry first: when that parse fails, the author gets the compiler's
339/// own diagnostic at the line, not a guess at the project's shape.
340pub(crate) fn entry_failure(
341    entry: &Path,
342    dep_roots: Vec<logicaffeine_compile::loader::DepRoot>,
343) -> CliError {
344    match crate::compile::compile_project_with_deps(entry, dep_roots.clone()) {
345        Err(e) => compile_error(&e, entry, &dep_roots),
346        Ok(_) => CliError::new(format!(
347            "{} could not be read as a program",
348            entry.display()
349        )),
350    }
351}
352
353/// The front end every command that runs or emits a program passes it through before choosing a
354/// backend: a program it rejects is reported once, in the words `largo` uses whichever backend was asked
355/// for, and no backend sees it. A program that reaches a module it never imported is refused here too.
356pub(crate) fn front_end(
357    entry: &Path,
358    dep_roots: &[logicaffeine_compile::loader::DepRoot],
359) -> Result<(), Box<dyn std::error::Error>> {
360    use logicaffeine_compile::compile::{CompileError, ProjectScopeError};
361    match logicaffeine_compile::compile::enforce_project_scope_with_deps(entry, dep_roots.to_vec())
362    {
363        Ok(()) => Ok(()),
364        Err(ProjectScopeError::Parse(_)) => {
365            match crate::compile::compile_project_with_deps(entry, dep_roots.to_vec()) {
366                Err(e @ CompileError::Parse(_)) => Err(compile_error(&e, entry, dep_roots).into()),
367                _ => Err(entry_failure(entry, dep_roots.to_vec()).into()),
368            }
369        }
370        Err(refused) => Err(refused.into()),
371    }
372}
373
374/// A `CompileError` from the entry's Rust pipeline, rendered: a parse error at its line, in
375/// whichever file it is actually in.
376pub(crate) fn compile_error(
377    e: &logicaffeine_compile::compile::CompileError,
378    entry: &Path,
379    dep_roots: &[logicaffeine_compile::loader::DepRoot],
380) -> CliError {
381    match e {
382        logicaffeine_compile::compile::CompileError::Parse(pe) => {
383            CliError::new(rejection(pe, located_in_project(pe, entry, dep_roots)))
384        }
385        other => CliError::new(other.to_string()),
386    }
387}
388
389/// A rejected program's diagnostic as the terminal shows it, placed at `at`, an unknown call located at
390/// its call site.
391fn rejection(pe: &ParseError, at: Placed) -> String {
392    match locate_unknown_call(pe, &at.text) {
393        Some(located) => {
394            let at = Placed::in_file(&at.file, at.text, located.span);
395            render_parse_error(&located, &at)
396        }
397        None => render_parse_error(pe, &at),
398    }
399}
400
401/// Everything `largo` writes to standard error for a program the compiler rejects with `pe`, whose span is
402/// in `text`, the text of `file`: the `error:` block with the explanation, the excerpt and the
403/// `--> file:line:col`, without terminal styles. Every backend `largo` drives reports a rejected program in
404/// exactly these bytes, and the tier-parity census holds the backends it drives in-process to them too.
405pub fn parse_error_report(file: &Path, text: &str, pe: &ParseError) -> String {
406    let message = rejection(pe, Placed::in_file(file, text.to_string(), pe.span));
407    crate::ui::plain(&crate::ui::error_block(&message, None))
408}
409
410/// A parse error from the entry's Rust pipeline, placed through the SAME closure the pipeline
411/// compiled: `compile_project_with_deps` assembles every imported module ahead of the entry into
412/// one combined source before parsing, so a span on that source is the entry's own coordinates
413/// only when nothing is imported. Rebuilding the identical closure here recovers the real file and
414/// line — an import's own mistake lands in that module, and the entry's own mistake lands
415/// correctly even with imports spliced ahead of it — through the same [`Placed::locate`] every
416/// other surface uses. Falls back to the entry's raw text when the closure itself will not
417/// assemble (a different failure than the one being reported).
418fn located_in_project(
419    pe: &ParseError,
420    entry: &Path,
421    dep_roots: &[logicaffeine_compile::loader::DepRoot],
422) -> Placed {
423    match crate::closure::assemble_closure_with_deps(entry, dep_roots.to_vec()) {
424        Ok(closure) => Placed::locate(pe.span, &closure.source, &closure.source_map, entry),
425        Err(_) => entry_placement(pe, entry),
426    }
427}
428
429/// The entry-file placement of a parse error, treating its span as the entry's own raw-text
430/// coordinates — correct only when the entry imports nothing. The fallback of
431/// [`located_in_project`] for when the closure cannot be reassembled.
432pub(crate) fn entry_placement(pe: &ParseError, entry: &Path) -> Placed {
433    let text = std::fs::read_to_string(entry).unwrap_or_default();
434    Placed::in_file(entry, text, pe.span)
435}
436
437/// The entry's parse error as a `logos-diagnostic/1` object, with an unknown call located at its
438/// call site.
439pub(crate) fn entry_parse_diagnostic(
440    pe: &ParseError,
441    entry: &Path,
442    dep_roots: &[logicaffeine_compile::loader::DepRoot],
443) -> Diagnostic {
444    let at = located_in_project(pe, entry, dep_roots);
445    match locate_unknown_call(pe, &at.text) {
446        Some(located) => {
447            let at = Placed::in_file(&at.file, at.text, located.span);
448            parse_error_diagnostic(&located, &at)
449        }
450        None => parse_error_diagnostic(pe, &at),
451    }
452}
453
454/// The byte ranges of `source` that are prose rather than code: everything before the first `##`
455/// header, every `## Note` block, and every backtick span — the places a name can be MENTIONED
456/// without being called.
457fn prose_ranges(source: &str) -> Vec<std::ops::Range<usize>> {
458    let mut out = Vec::new();
459    let mut offset = 0usize;
460    let mut prose_from: Option<usize> = Some(0);
461    for line in source.split_inclusive('\n') {
462        let t = line.trim_start();
463        if t.starts_with("## ") || t == "##\n" || t == "##" {
464            if let Some(from) = prose_from.take() {
465                out.push(from..offset);
466            }
467            if t.starts_with("## Note") {
468                prose_from = Some(offset);
469            }
470        }
471        let mut in_tick: Option<usize> = None;
472        for (i, c) in line.char_indices() {
473            if c == '`' {
474                match in_tick.take() {
475                    Some(from) => out.push(offset + from..offset + i + 1),
476                    None => in_tick = Some(i),
477                }
478            }
479        }
480        offset += line.len();
481    }
482    if let Some(from) = prose_from {
483        out.push(from..offset);
484    }
485    out
486}
487
488/// If `pe` is a span-less "Unknown function: <name>" diagnostic, a copy whose span points at the
489/// name's CALL site in `source`. The AST's `Expr::Call` carries no span, so the checker emits this
490/// diagnostic span-less; the location is recovered from the SAME text the caret is drawn against,
491/// skipping prose — a `## Note` or a backticked mention names the function without calling it.
492/// Prefers a call position (`name(` or `name of`); `None` for any other error or a name not found.
493pub(crate) fn locate_unknown_call(pe: &ParseError, source: &str) -> Option<ParseError> {
494    let msg = match &pe.kind {
495        ParseErrorKind::Custom(s) => s.as_str(),
496        _ => return None,
497    };
498    let name = msg.strip_prefix("Unknown function: ")?.trim();
499    if name.is_empty() {
500        return None;
501    }
502    let prose = prose_ranges(source);
503    let in_prose = |at: usize| prose.iter().any(|r| r.contains(&at));
504    let bytes = source.as_bytes();
505    let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
506    let mut fallback: Option<usize> = None;
507    let mut from = 0;
508    while let Some(rel) = source[from..].find(name) {
509        let at = from + rel;
510        let end = at + name.len();
511        let bounded =
512            (at == 0 || !is_ident(bytes[at - 1])) && (end >= bytes.len() || !is_ident(bytes[end]));
513        if bounded && !in_prose(at) {
514            let mut k = end;
515            while k < bytes.len() && bytes[k] == b' ' {
516                k += 1;
517            }
518            if k < bytes.len() && (bytes[k] == b'(' || source[k..].starts_with("of ")) {
519                return Some(ParseError {
520                    kind: pe.kind.clone(),
521                    span: Span::new(at, end),
522                });
523            }
524            fallback.get_or_insert(at);
525        }
526        from = end;
527    }
528    fallback.map(|at| ParseError {
529        kind: pe.kind.clone(),
530        span: Span::new(at, at + name.len()),
531    })
532}
533
534/// A failure that belongs to a whole file rather than a line in it — a module that will not
535/// assemble, a backend error with no source position — as a `logos-diagnostic/1` object that
536/// still names the file.
537pub(crate) fn file_failure(file: &Path, code: &str, message: &str) -> Diagnostic {
538    Diagnostic {
539        code: code.to_string(),
540        severity: "error".to_string(),
541        file: Some(file.display().to_string()),
542        span: DiagSpan {
543            start: 0,
544            end: 0,
545            start_line: 1,
546            start_col: 1,
547            end_line: 1,
548            end_col: 1,
549        },
550        message: message.lines().next().unwrap_or(message).to_string(),
551        explanation: message.to_string(),
552        suggestion: None,
553        quickfix: None,
554        docs: None,
555        related: Vec::new(),
556    }
557}
558
559/// The `logos-check/1` envelope as a single pretty JSON document.
560pub(crate) fn check_envelope(ok: bool, diagnostics: &[Diagnostic]) -> String {
561    let doc = serde_json::json!({
562        "schema": "logos-check/1",
563        "ok": ok,
564        "diagnostics": diagnostics,
565    });
566    serde_json::to_string_pretty(&doc).expect("envelope serializes")
567}
568
569/// The `logos-fmt/1` envelope.
570pub(crate) fn fmt_envelope(clean: bool, unformatted: &[String]) -> String {
571    let doc = serde_json::json!({
572        "schema": "logos-fmt/1",
573        "clean": clean,
574        "unformatted": unformatted,
575    });
576    serde_json::to_string_pretty(&doc).expect("envelope serializes")
577}
578
579/// The `logos-run/1` envelope: one run's outcome, output, and step account.
580pub(crate) fn run_envelope(
581    outcome: &str,
582    lines: &[String],
583    error: Option<&str>,
584    steps_used: Option<u64>,
585) -> String {
586    let doc = serde_json::json!({
587        "schema": "logos-run/1",
588        "outcome": outcome,
589        "lines": lines,
590        "error": error,
591        "steps_used": steps_used,
592    });
593    serde_json::to_string_pretty(&doc).expect("envelope serializes")
594}
595
596/// A `logos-check/1` envelope for a failure that is not a compile error — the cargo phase of
597/// `largo build --json`. The raw tool output rides in `explanation`, never dropped. There is no
598/// source location to give, so `file` is `null`.
599pub(crate) fn build_failure_envelope(message: &str, explanation: &str) -> String {
600    let doc = serde_json::json!({
601        "schema": "logos-check/1",
602        "ok": false,
603        "diagnostics": [{
604            "code": "build-failure",
605            "severity": "error",
606            "file": null,
607            "span": {"start": 0, "end": 0, "start_line": 1, "start_col": 1,
608                      "end_line": 1, "end_col": 1},
609            "message": message,
610            "explanation": explanation,
611            "suggestion": null,
612            "quickfix": null,
613            "docs": null,
614            "related": [],
615        }],
616    });
617    serde_json::to_string_pretty(&doc).expect("envelope serializes")
618}
619
620#[cfg(test)]
621#[path = "../../../crates/logicaffeine_tests/unit/logicaffeine_cli/diagnostics/tests.rs"]
622mod tests;