1use 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#[derive(Debug, Clone)]
29pub(crate) struct Placed {
30 pub file: PathBuf,
32 pub text: String,
34 pub span: Span,
36}
37
38impl Placed {
39 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 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 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 pub(crate) fn line_col(&self) -> (usize, usize) {
85 line_col(&self.text, self.span.start)
86 }
87
88 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 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
111fn 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
119fn 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
133fn 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
144fn 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
156fn header_start(text: &str) -> usize {
160 text.find("\n## ").map(|i| i + 1).unwrap_or(0)
161}
162
163fn placed_error(pe: &ParseError, at: &Placed) -> ParseError {
165 ParseError {
166 kind: pe.kind.clone(),
167 span: at.span,
168 }
169}
170
171fn 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
181pub(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
188pub(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
199pub(crate) fn finding_severity(f: &Finding) -> &'static str {
201 match f.severity {
202 Severity::Error => "error",
203 Severity::Warning => "warning",
204 }
205}
206
207pub(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
229pub(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
243pub(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
259pub(crate) fn copy_finding_severity(f: &CopyFinding) -> &'static str {
262 match f.severity {
263 CopySeverity::Warning => "warning",
264 }
265}
266
267pub(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
289pub(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
304pub(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
320pub(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
335pub(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
353pub(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
374pub(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
389fn 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
401pub 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
410fn 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
429pub(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
437pub(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
454fn 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
488pub(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
534pub(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
559pub(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
569pub(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
579pub(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
596pub(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;