Skip to content

Commit 6ee408e

Browse files
authored
Merge pull request #29420 from ProvableHQ/josh/diagnostics
feat(LSP) Implement diagnostics and error publishing.
2 parents 4947f59 + b71c648 commit 6ee408e

15 files changed

Lines changed: 2051 additions & 68 deletions

File tree

crates/errors/src/common/backtraced.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,14 @@ impl Backtraced {
117117
pub fn warning_code(&self) -> String {
118118
format_warning_code(&self.type_, 37, self.code)
119119
}
120+
121+
/// Return whether this diagnostic represents an error rather than a warning.
122+
///
123+
/// LSP severity mapping inspects this flag rather than the rendered prefix
124+
/// in the diagnostic's `Display` output.
125+
pub fn is_error(&self) -> bool {
126+
self.error
127+
}
120128
}
121129

122130
impl fmt::Display for Backtraced {

crates/errors/src/common/formatted.rs

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,22 @@ impl Label {
4747
self.color = color;
4848
self
4949
}
50+
51+
/// Borrow the secondary label's message without copying.
52+
///
53+
/// Surfaced for `Formatted::diagnostic_view`, which lowers labels into
54+
/// LSP-facing related-information entries without re-parsing rendered text.
55+
pub fn message(&self) -> &str {
56+
&self.msg
57+
}
58+
59+
/// Return the secondary label's source span.
60+
///
61+
/// Used by structured diagnostic consumers (notably `leo-lsp`) to convert
62+
/// labels into UTF-16 ranges while the session source map is still alive.
63+
pub fn span(&self) -> Span {
64+
self.span
65+
}
5066
}
5167

5268
/// Helper span for Ariadne that includes the source file start index.
@@ -173,6 +189,83 @@ impl Formatted {
173189
format_warning_code(&self.inner.type_, 37, self.inner.code)
174190
}
175191

192+
/// Return the diagnostic's primary message without ariadne rendering.
193+
///
194+
/// Used by tooling consumers (notably `leo-lsp`) that need the bare message
195+
/// text rather than the formatted report. The returned slice borrows from
196+
/// the same allocation as the rest of the diagnostic and therefore stays
197+
/// valid for the lifetime of the `Formatted` value.
198+
pub fn message(&self) -> &str {
199+
&self.inner.message
200+
}
201+
202+
/// Return the diagnostic's optional help text, if any.
203+
///
204+
/// `leo-lsp` appends this to the LSP diagnostic message so editor clients
205+
/// see the same hint that the CLI report would render.
206+
pub fn help(&self) -> Option<&str> {
207+
self.inner.help.as_deref()
208+
}
209+
210+
/// Return the diagnostic's optional follow-up note text, if any.
211+
///
212+
/// `leo-lsp` appends this to the LSP diagnostic message for parity with
213+
/// CLI-rendered reports.
214+
pub fn note(&self) -> Option<&str> {
215+
self.inner.note.as_deref()
216+
}
217+
218+
/// Return whether this diagnostic was raised as an error rather than a
219+
/// warning.
220+
///
221+
/// LSP severity mapping depends on this flag instead of inspecting the
222+
/// rendered `Error`/`Warning` prefix in the formatted message.
223+
pub fn is_error(&self) -> bool {
224+
self.inner.error
225+
}
226+
227+
/// Return the diagnostic's primary span.
228+
///
229+
/// Callers must resolve this span against `leo_span` session globals to
230+
/// recover the originating source file before the surrounding session is
231+
/// torn down.
232+
pub fn span(&self) -> Span {
233+
self.inner.span
234+
}
235+
236+
/// Iterate the diagnostic's secondary labels in declaration order.
237+
///
238+
/// Labels carry their own span and human-readable message, which `leo-lsp`
239+
/// surfaces as `Diagnostic.relatedInformation` when the client supports it.
240+
pub fn labels(&self) -> impl Iterator<Item = &Label> {
241+
self.inner.labels.iter()
242+
}
243+
244+
/// Borrow this diagnostic as a plain structured view.
245+
///
246+
/// The returned [`DiagnosticView`] exposes the same fields that are used
247+
/// when rendering the ariadne report, without round-tripping through a
248+
/// formatted string. Consumers like `leo-lsp` use the view to build LSP
249+
/// `Diagnostic` payloads without parsing rendered output.
250+
pub fn diagnostic_view(&self) -> DiagnosticView<'_> {
251+
let code = if self.inner.error { self.error_code() } else { self.warning_code() };
252+
let labels = self
253+
.inner
254+
.labels
255+
.iter()
256+
.map(|label| DiagnosticLabelView { message: label.message().to_owned(), span: label.span() })
257+
.collect();
258+
DiagnosticView {
259+
message: &self.inner.message,
260+
help: self.inner.help.as_deref(),
261+
note: self.inner.note.as_deref(),
262+
code,
263+
is_error: self.inner.error,
264+
span: Some(self.inner.span),
265+
labels,
266+
}
267+
}
268+
176269
/// Resolve a Leo `Span` to an `AriadneSpan` using the source map.
177270
fn resolve_span(span: Span, source_map: &leo_span::source_map::SourceMap) -> AriadneSpan {
178271
let file_start_index = source_map.find_source_file(span.lo).unwrap().absolute_start;
@@ -265,3 +358,91 @@ impl std::error::Error for Formatted {
265358
&self.inner.message
266359
}
267360
}
361+
362+
/// LSP-agnostic structured view of a compiler diagnostic.
363+
///
364+
/// Exposed so editor tooling — currently `leo-lsp` — can lower errors and
365+
/// warnings into editor-facing diagnostics without parsing rendered ariadne
366+
/// output. The view borrows from the originating [`Formatted`] for cheap
367+
/// strings while owning a small per-label `Vec`, which is the smallest shape
368+
/// that keeps secondary-label messages alive across an `extract_errs` call.
369+
#[derive(Debug, Clone)]
370+
pub struct DiagnosticView<'a> {
371+
/// Primary human-readable message.
372+
pub message: &'a str,
373+
/// Optional help hint shown beneath the diagnostic on the CLI.
374+
pub help: Option<&'a str>,
375+
/// Optional follow-up note shown beneath the help line on the CLI.
376+
pub note: Option<&'a str>,
377+
/// Fully formatted code identifier (e.g. `EPAR0001` or `WTYC0001`).
378+
pub code: String,
379+
/// Whether the diagnostic is an error (`true`) or a warning (`false`).
380+
pub is_error: bool,
381+
/// Primary span, when the diagnostic ties to a concrete source location.
382+
pub span: Option<Span>,
383+
/// Secondary spans annotated with their own human-readable messages.
384+
pub labels: Vec<DiagnosticLabelView>,
385+
}
386+
387+
/// One secondary label paired with its source span.
388+
///
389+
/// `leo-lsp` lowers each label into `Diagnostic.relatedInformation` when the
390+
/// client advertises support, so it captures both the span and the message.
391+
#[derive(Debug, Clone)]
392+
pub struct DiagnosticLabelView {
393+
/// Human-readable description for the label.
394+
pub message: String,
395+
/// Span associated with the label, resolved against session source globals.
396+
pub span: Span,
397+
}
398+
399+
#[cfg(test)]
400+
mod tests {
401+
use super::{Color, Formatted, Label};
402+
use leo_span::{Span, create_session_if_not_set_then};
403+
404+
/// Verifies the structured view round-trips primary message, code, help, and note.
405+
#[test]
406+
fn diagnostic_view_exposes_primary_fields() {
407+
create_session_if_not_set_then(|_| {
408+
let span = Span::default();
409+
let error = Formatted::error("TST", 1, "boom", span).with_help("try again").with_note("note text");
410+
411+
let view = error.diagnostic_view();
412+
assert_eq!(view.message, "boom");
413+
assert_eq!(view.help, Some("try again"));
414+
assert_eq!(view.note, Some("note text"));
415+
assert_eq!(view.code, error.error_code());
416+
assert!(view.is_error);
417+
assert_eq!(view.span, Some(span));
418+
assert!(view.labels.is_empty());
419+
});
420+
}
421+
422+
/// Verifies labels are exposed with their messages and spans intact.
423+
#[test]
424+
fn diagnostic_view_exposes_secondary_labels() {
425+
create_session_if_not_set_then(|_| {
426+
let primary = Span::new(0, 4);
427+
let label_span = Span::new(5, 10);
428+
let error = Formatted::error("TST", 2, "boom", primary)
429+
.with_label(Label::new(label_span).with_message("see also").with_color(Color::Blue));
430+
431+
let view = error.diagnostic_view();
432+
assert_eq!(view.labels.len(), 1);
433+
assert_eq!(view.labels[0].message, "see also");
434+
assert_eq!(view.labels[0].span, label_span);
435+
});
436+
}
437+
438+
/// Verifies warnings round-trip through the structured view with severity preserved.
439+
#[test]
440+
fn diagnostic_view_marks_warnings() {
441+
create_session_if_not_set_then(|_| {
442+
let warning = Formatted::warning("TST", 3, "watch out", Span::default());
443+
let view = warning.diagnostic_view();
444+
assert!(!view.is_error);
445+
assert_eq!(view.code, warning.warning_code());
446+
});
447+
}
448+
}

crates/errors/src/errors/mod.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,36 @@ impl LeoError {
5656
SnarkVM(_) => 11000,
5757
}
5858
}
59+
60+
/// Borrow a structured, LSP-agnostic view of this error.
61+
///
62+
/// Only [`LeoError::Formatted`] variants carry a span and labeled
63+
/// secondary information, so other variants return `None`. The caller is
64+
/// expected to fall back to a synthetic package-level diagnostic in that
65+
/// case; see the `leo-lsp` diagnostics module for an example.
66+
///
67+
/// [`LeoError::LastErrorCode`] is a sentinel used to signal that an error
68+
/// has already been emitted through a handler. It deliberately returns
69+
/// `None` so it is never published as a separate diagnostic.
70+
pub fn diagnostic_view(&self) -> Option<crate::DiagnosticView<'_>> {
71+
match self {
72+
LeoError::Formatted(formatted) => Some(formatted.diagnostic_view()),
73+
LeoError::Backtraced(_)
74+
| LeoError::ConstEvalError(_)
75+
| LeoError::LastErrorCode(_)
76+
| LeoError::SnarkVM(_) => None,
77+
}
78+
}
79+
80+
/// Return whether this error is the sentinel raised after a handler emit.
81+
///
82+
/// `Handler::last_err` produces [`LeoError::LastErrorCode`] when the
83+
/// emitter has already buffered a real diagnostic. Consumers should skip
84+
/// the sentinel when collecting structured diagnostics so the original
85+
/// error is published exactly once.
86+
pub fn is_last_error_code(&self) -> bool {
87+
matches!(self, LeoError::LastErrorCode(_))
88+
}
5989
}
6090

6191
/// The LeoWarning type that contains all sub warning types.
@@ -72,6 +102,18 @@ impl LeoWarning {
72102
Formatted(w) => w.warning_code(),
73103
}
74104
}
105+
106+
/// Borrow a structured, LSP-agnostic view of this warning.
107+
///
108+
/// Every variant currently wraps a [`Formatted`] payload, so the view is
109+
/// always available. The signature is kept symmetric with
110+
/// [`LeoError::diagnostic_view`] so future variants that lack span data
111+
/// can opt out without breaking call sites.
112+
pub fn diagnostic_view(&self) -> Option<crate::DiagnosticView<'_>> {
113+
match self {
114+
LeoWarning::Formatted(formatted) => Some(formatted.diagnostic_view()),
115+
}
116+
}
75117
}
76118

77119
/// A global result type for all Leo crates, that defaults the errors to be a LeoError.

crates/lsp/Cargo.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,6 @@ tracing-subscriber = { workspace = true }
4343
xxhash-rust = { workspace = true }
4444

4545
[dev-dependencies]
46-
serde_json = { workspace = true }
47-
tempfile = { workspace = true }
46+
crossbeam-channel = { workspace = true }
47+
serde_json = { workspace = true }
48+
tempfile = { workspace = true }

0 commit comments

Comments
 (0)