Skip to main content

oak_diagnostic/
lib.rs

1#![feature(new_range_api)]
2#![warn(missing_docs)]
3//! Diagnostic reporting for the Oak language framework.
4//!
5//! This crate provides structures and traits for representing and managing
6//! diagnostics (errors, warnings, advice) in a way that is compatible with
7//! various frontends like LSP or CLI output.
8
9use oak_core::{
10    errors::{OakError, OakErrorKind},
11    source::Source,
12};
13use oak_vfs::LineMap;
14
15/// Severity of a diagnostic.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
18pub enum Severity {
19    /// An error that must be fixed.
20    Error,
21    /// A warning that should be addressed.
22    Warning,
23    /// An advice or suggestion for improvement.
24    Advice,
25}
26
27/// A labeled region in the source code.
28#[derive(Debug, Clone)]
29#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
30pub struct Label {
31    /// The message associated with the label.
32    pub message: Option<String>,
33    /// The byte range within the resource.
34    #[cfg_attr(feature = "serde", serde(with = "oak_core::serde_range"))]
35    pub span: core::range::Range<usize>,
36    /// The color of the label (optional).
37    pub color: Option<String>,
38}
39
40/// A diagnostic message.
41#[derive(Debug, Clone)]
42#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
43pub struct Diagnostic {
44    /// The diagnostic code.
45    pub code: Option<String>,
46    /// The primary message.
47    pub message: String,
48    /// The internationalization key.
49    pub i18n_key: Option<String>,
50    /// The internationalization arguments.
51    pub i18n_args: std::collections::HashMap<String, String>,
52    /// The severity of the diagnostic.
53    pub severity: Severity,
54    /// The labeled regions in the source.
55    pub labels: Vec<Label>,
56    /// A help message providing more details or suggestions.
57    pub help: Option<String>,
58}
59
60impl Diagnostic {
61    /// Creates a new error diagnostic with the given message.
62    pub fn error(message: impl Into<String>) -> Self {
63        Self { code: None, message: message.into(), i18n_key: None, i18n_args: std::collections::HashMap::new(), severity: Severity::Error, labels: Vec::new(), help: None }
64    }
65
66    /// Creates a new warning diagnostic with the given message.
67    pub fn warning(message: impl Into<String>) -> Self {
68        Self { code: None, message: message.into(), i18n_key: None, i18n_args: std::collections::HashMap::new(), severity: Severity::Warning, labels: Vec::new(), help: None }
69    }
70
71    /// Sets the internationalization key for the diagnostic.
72    pub fn with_i18n(mut self, key: impl Into<String>) -> Self {
73        self.i18n_key = Some(key.into());
74        self
75    }
76
77    /// Adds an internationalization argument to the diagnostic.
78    pub fn with_arg(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
79        self.i18n_args.insert(key.into(), value.into());
80        self
81    }
82
83    /// Creates a diagnostic from a provider and a source.
84    pub fn from_provider<P: DiagnosticProvider, S: Source + ?Sized>(provider: &P, source: &S) -> Self {
85        provider.to_diagnostic(source)
86    }
87
88    /// Adds a labeled region to the diagnostic.
89    pub fn with_label(mut self, span: core::range::Range<usize>, message: impl Into<String>) -> Self {
90        self.labels.push(Label { message: Some(message.into()), span, color: None });
91        self
92    }
93
94    /// Adds a help message to the diagnostic.
95    pub fn with_help(mut self, help: impl Into<String>) -> Self {
96        self.help = Some(help.into());
97        self
98    }
99
100    /// Sets the diagnostic code.
101    pub fn with_code(mut self, code: impl Into<String>) -> Self {
102        self.code = Some(code.into());
103        self
104    }
105}
106
107impl From<&OakError> for Diagnostic {
108    fn from(error: &OakError) -> Self {
109        let message = format!("{}", error);
110        Diagnostic::error(message)
111    }
112}
113
114/// A trait for objects that can be converted into a diagnostic.
115pub trait DiagnosticProvider {
116    /// Converts this object into a diagnostic message using the given source provider.
117    fn to_diagnostic<S: Source + ?Sized>(&self, source: &S) -> Diagnostic;
118}
119
120/// A provider that emits diagnostics for a language.
121pub trait OakDiagnosticsProvider<L: oak_core::Language> {
122    /// Emits diagnostics for the given syntax tree and source.
123    fn emit_diagnostics<S: Source + ?Sized>(&self, uri: &str, root: &oak_core::tree::RedNode<L>, source: &S) -> Vec<Diagnostic>;
124    /// Emits all diagnostics for the given source.
125    fn emit_all_diagnostics<S: Source + ?Sized>(&self, uri: &str, source: &S) -> Vec<Diagnostic> {
126        let _ = (uri, source);
127        Vec::new()
128    }
129}
130
131impl DiagnosticProvider for OakError {
132    fn to_diagnostic<S: Source + ?Sized>(&self, source: &S) -> Diagnostic {
133        let kind = self.kind();
134        let message = kind.to_string();
135        let code = Some(kind.key().to_string());
136
137        let mut diag = Diagnostic::error(message).with_code(code.unwrap()).with_i18n(kind.key());
138
139        match kind {
140            OakErrorKind::IoError { .. } => {}
141            OakErrorKind::SyntaxError { offset, .. } | OakErrorKind::UnexpectedCharacter { offset, .. } => {
142                let start = (*offset).min(source.length());
143                let end = (start + 1).min(source.length());
144                diag = diag.with_label(core::range::Range { start, end }, "here");
145            }
146            OakErrorKind::UnexpectedToken { token, offset, .. } => {
147                let start = (*offset).min(source.length());
148                let end = (start + 1).min(source.length());
149                diag = diag.with_label(core::range::Range { start, end }, "here").with_arg("token", token.clone());
150            }
151            OakErrorKind::ExpectedToken { expected, offset, .. } => {
152                let start = (*offset).min(source.length());
153                let end = (start + 1).min(source.length());
154                diag = diag.with_label(core::range::Range { start, end }, "here").with_arg("expected", expected.clone());
155            }
156            OakErrorKind::ExpectedName { name_kind, offset, .. } => {
157                let start = (*offset).min(source.length());
158                let end = (start + 1).min(source.length());
159                diag = diag.with_label(core::range::Range { start, end }, "here").with_arg("name_kind", name_kind.clone());
160            }
161            OakErrorKind::TrailingCommaNotAllowed { offset, .. } => {
162                let start = (*offset).min(source.length());
163                let end = (start + 1).min(source.length());
164                diag = diag.with_label(core::range::Range { start, end }, "here");
165            }
166            _ => {}
167        }
168
169        diag
170    }
171}
172
173/// A trait for localizing diagnostic messages.
174pub trait Localizer {
175    /// Localize a message given its key and arguments.
176    fn localize(&self, key: &str, args: &std::collections::HashMap<String, String>) -> String;
177}
178
179impl Localizer for () {
180    fn localize(&self, _key: &str, _args: &std::collections::HashMap<String, String>) -> String {
181        String::new()
182    }
183}
184
185/// A trait for emitting diagnostics.
186pub trait Emitter {
187    /// Render a diagnostic to a string.
188    fn render<S: Source + ?Sized>(&self, source: &S, diagnostic: &Diagnostic) -> String {
189        self.render_localized::<S, ()>(source, diagnostic, None, None)
190    }
191
192    /// Render a diagnostic to a string with an optional localizer and URI lookup.
193    fn render_localized<S: Source + ?Sized, L: Localizer + ?Sized>(&self, source: &S, diagnostic: &Diagnostic, localizer: Option<&L>, uri: Option<&str>) -> String;
194}
195
196/// Emitter for ANSI-colored console output.
197pub struct ConsoleEmitter {
198    /// Whether to use Unicode characters for drawing boxes and lines.
199    pub unicode: bool,
200}
201
202impl Default for ConsoleEmitter {
203    fn default() -> Self {
204        Self { unicode: true }
205    }
206}
207
208impl Emitter for ConsoleEmitter {
209    fn render_localized<S: Source + ?Sized, L: Localizer + ?Sized>(&self, source: &S, diagnostic: &Diagnostic, localizer: Option<&L>, uri: Option<&str>) -> String {
210        let mut out = String::new();
211        let line_map = LineMap::from_source(source);
212        let full_text = source.get_text_in(core::range::Range { start: 0, end: source.length() }).into_owned();
213        let lines: Vec<&str> = full_text.lines().collect();
214
215        // 1. Header: [code] severity: message or [severity]: message
216        let sev_name = match diagnostic.severity {
217            Severity::Error => "error",
218            Severity::Warning => "warning",
219            Severity::Advice => "advice",
220        };
221        let sev_color = match diagnostic.severity {
222            Severity::Error => "\x1b[31;1m",
223            Severity::Warning => "\x1b[33;1m",
224            Severity::Advice => "\x1b[36;1m",
225        };
226
227        let message = if let (Some(key), Some(loc)) = (&diagnostic.i18n_key, localizer) { loc.localize(key, &diagnostic.i18n_args) } else { diagnostic.message.clone() };
228
229        if let Some(code) = &diagnostic.code {
230            out.push_str(&format!("{}[{}]\x1b[0m {}\n", sev_color, code, message));
231        }
232        else {
233            out.push_str(&format!("{}[{}]\x1b[0m {}\n", sev_color, sev_name, message));
234        }
235
236        // 2. Snippets
237        for label in &diagnostic.labels {
238            self.render_snippet(&mut out, source, &line_map, &full_text, &lines, label, uri);
239        }
240
241        // 3. Help
242        if let Some(help) = &diagnostic.help {
243            out.push_str(&format!("\n\x1b[36;1mhelp\x1b[0m: {}\n", help));
244        }
245
246        out
247    }
248}
249
250struct Characters {
251    vbar: &'static str,
252    hbar: &'static str,
253    ltop: &'static str,
254    lbot: &'static str,
255}
256
257impl Characters {
258    fn unicode() -> Self {
259        Self { vbar: "│", hbar: "─", ltop: "┌", lbot: "└" }
260    }
261
262    fn ascii() -> Self {
263        Self { vbar: "|", hbar: "_", ltop: "/", lbot: "|" }
264    }
265}
266
267impl ConsoleEmitter {
268    fn render_snippet<S: Source + ?Sized>(&self, out: &mut String, source: &S, line_map: &LineMap, full_text: &str, lines: &[&str], label: &Label, uri: Option<&str>) {
269        let chars = if self.unicode { Characters::unicode() } else { Characters::ascii() };
270        let (start_line, _) = line_map.offset_to_line_col_utf16(source, label.span.start);
271        let (end_line, _) = line_map.offset_to_line_col_utf16(source, label.span.end);
272        let start_line = start_line as usize;
273        let end_line = end_line as usize;
274        let start_line_start = line_map.line_start(start_line as u32).unwrap_or(0);
275        let end_line_start = line_map.line_start(end_line as u32).unwrap_or(0);
276        let start_col = full_text.get(start_line_start..label.span.start.min(full_text.len())).unwrap_or("").chars().count();
277        let end_col = full_text.get(end_line_start..label.span.end.min(full_text.len())).unwrap_or("").chars().count();
278
279        let line_num_width = (end_line + 1).to_string().len();
280        let padding = " ".repeat(line_num_width);
281
282        // Location info: ┌ at url:line:col
283        let url_str = uri.unwrap_or("<anonymous>");
284        let pos_str = format!("{}:{}", start_line + 1, start_col + 1);
285        out.push_str(&format!("  \x1b[34m{}\x1b[0m at {}:{}\n", chars.ltop, url_str, pos_str));
286        out.push_str(&format!("{} \x1b[34m{}\x1b[0m\n", padding, chars.vbar));
287
288        if start_line == end_line {
289            // Single line label
290            if let Some(line_text) = lines.get(start_line) {
291                out.push_str(&format!("{:>width$} \x1b[34m{}\x1b[0m {}\n", start_line + 1, chars.vbar, line_text, width = line_num_width));
292
293                let underline_padding = " ".repeat(start_col);
294                let underline_len = full_text.get(label.span.start.min(full_text.len())..label.span.end.min(full_text.len())).unwrap_or("").chars().count().max(1);
295                let underline = "^".repeat(underline_len);
296
297                let color = label.color.as_deref().unwrap_or("\x1b[31;1m");
298                out.push_str(&format!("{} \x1b[34m{}\x1b[0m {}{}{}\x1b[0m", padding, chars.vbar, underline_padding, color, underline));
299
300                if let Some(msg) = &label.message {
301                    out.push_str(&format!(" {}\n", msg));
302                }
303                else {
304                    out.push_str("\n");
305                }
306            }
307        }
308        else {
309            // Multi-line label
310            let color = label.color.as_deref().unwrap_or("\x1b[31;1m");
311            for i in start_line..=end_line {
312                if let Some(line_text) = lines.get(i) {
313                    let line_num = i + 1;
314                    if i == start_line {
315                        out.push_str(&format!("{:>width$} \x1b[34m{}\x1b[0m {}{}{} {}\n", line_num, chars.vbar, color, chars.ltop, "\x1b[0m", line_text, width = line_num_width));
316                    }
317                    else if i == end_line {
318                        out.push_str(&format!("{:>width$} \x1b[34m{}\x1b[0m {}{}{} {}\n", line_num, chars.vbar, color, chars.vbar, "\x1b[0m", line_text, width = line_num_width));
319
320                        let underline_len = end_col.max(1);
321                        let underline = "^".repeat(underline_len);
322                        out.push_str(&format!("{} \x1b[34m{}\x1b[0m {}{}{}{}", padding, chars.vbar, color, chars.lbot, chars.hbar.repeat(end_col), underline));
323
324                        if let Some(msg) = &label.message {
325                            out.push_str(&format!(" {}\x1b[0m\n", msg));
326                        }
327                        else {
328                            out.push_str("\x1b[0m\n");
329                        }
330                    }
331                    else {
332                        out.push_str(&format!("{:>width$} \x1b[34m{}\x1b[0m {}{}{} {}\n", line_num, chars.vbar, color, chars.vbar, "\x1b[0m", line_text, width = line_num_width));
333                    }
334                }
335            }
336        }
337        out.push_str(&format!("{} \x1b[34m{}\x1b[0m\n", padding, chars.vbar));
338    }
339}
340
341/// Emitter for plain text output without colors.
342pub struct PlainTextEmitter {
343    /// Whether to use Unicode characters for drawing boxes and lines.
344    pub unicode: bool,
345}
346
347impl Default for PlainTextEmitter {
348    fn default() -> Self {
349        Self { unicode: false }
350    }
351}
352
353impl Emitter for PlainTextEmitter {
354    fn render_localized<S: Source + ?Sized, L: Localizer + ?Sized>(&self, source: &S, diagnostic: &Diagnostic, localizer: Option<&L>, uri: Option<&str>) -> String {
355        let mut out = String::new();
356        let line_map = LineMap::from_source(source);
357        let full_text = source.get_text_in(core::range::Range { start: 0, end: source.length() }).into_owned();
358        let lines: Vec<&str> = full_text.lines().collect();
359
360        // 1. Header: [code] severity: message or [severity]: message
361        let sev_name = match diagnostic.severity {
362            Severity::Error => "error",
363            Severity::Warning => "warning",
364            Severity::Advice => "advice",
365        };
366
367        let message = if let (Some(key), Some(loc)) = (&diagnostic.i18n_key, localizer) { loc.localize(key, &diagnostic.i18n_args) } else { diagnostic.message.clone() };
368
369        if let Some(code) = &diagnostic.code {
370            out.push_str(&format!("[{}] {}\n", code, message));
371        }
372        else {
373            out.push_str(&format!("[{}] {}\n", sev_name, message));
374        }
375
376        // 2. Snippets
377        for label in &diagnostic.labels {
378            self.render_snippet(&mut out, source, &line_map, &full_text, &lines, label, uri);
379        }
380
381        // 3. Help
382        if let Some(help) = &diagnostic.help {
383            out.push_str(&format!("\nhelp: {}\n", help));
384        }
385
386        out
387    }
388}
389
390impl PlainTextEmitter {
391    fn render_snippet<S: Source + ?Sized>(&self, out: &mut String, source: &S, line_map: &LineMap, full_text: &str, lines: &[&str], label: &Label, uri: Option<&str>) {
392        let chars = if self.unicode { Characters::unicode() } else { Characters::ascii() };
393        let (start_line, _) = line_map.offset_to_line_col_utf16(source, label.span.start);
394        let (end_line, _) = line_map.offset_to_line_col_utf16(source, label.span.end);
395        let start_line = start_line as usize;
396        let end_line = end_line as usize;
397        let start_line_start = line_map.line_start(start_line as u32).unwrap_or(0);
398        let end_line_start = line_map.line_start(end_line as u32).unwrap_or(0);
399        let start_col = full_text.get(start_line_start..label.span.start.min(full_text.len())).unwrap_or("").chars().count();
400        let end_col = full_text.get(end_line_start..label.span.end.min(full_text.len())).unwrap_or("").chars().count();
401
402        let line_num_width = (end_line + 1).to_string().len();
403        let padding = " ".repeat(line_num_width);
404
405        // Location info: ┌ at url:line:col
406        let url_str = uri.unwrap_or("<anonymous>");
407        let pos_str = format!("{}:{}", start_line + 1, start_col + 1);
408        out.push_str(&format!("  {} at {}:{}\n", chars.ltop, url_str, pos_str));
409        out.push_str(&format!("{} {}\n", padding, chars.vbar));
410
411        if start_line == end_line {
412            if let Some(line_text) = lines.get(start_line) {
413                out.push_str(&format!("{:>width$} {} {}\n", start_line + 1, chars.vbar, line_text, width = line_num_width));
414                let underline_padding = " ".repeat(start_col);
415                let underline_len = full_text.get(label.span.start.min(full_text.len())..label.span.end.min(full_text.len())).unwrap_or("").chars().count().max(1);
416                let underline = "^".repeat(underline_len);
417                out.push_str(&format!("{} {} {}{}", padding, chars.vbar, underline_padding, underline));
418                if let Some(msg) = &label.message {
419                    out.push_str(&format!(" {}\n", msg));
420                }
421                else {
422                    out.push_str("\n");
423                }
424            }
425        }
426        else {
427            for i in start_line..=end_line {
428                if let Some(line_text) = lines.get(i) {
429                    let line_num = i + 1;
430                    if i == start_line {
431                        out.push_str(&format!("{:>width$} {} {}{}\n", line_num, chars.vbar, chars.ltop, line_text, width = line_num_width));
432                    }
433                    else if i == end_line {
434                        out.push_str(&format!("{:>width$} {} {}{}\n", line_num, chars.vbar, chars.vbar, line_text, width = line_num_width));
435
436                        let underline_len = end_col.max(1);
437                        let underline = "^".repeat(underline_len);
438                        out.push_str(&format!("{} {} {}{}{}", padding, chars.vbar, chars.lbot, chars.hbar.repeat(end_col), underline));
439
440                        if let Some(msg) = &label.message {
441                            out.push_str(&format!(" {}\n", msg));
442                        }
443                        else {
444                            out.push_str("\n");
445                        }
446                    }
447                    else {
448                        out.push_str(&format!("{:>width$} {} {}{}\n", line_num, chars.vbar, chars.vbar, line_text, width = line_num_width));
449                    }
450                }
451            }
452        }
453        out.push_str(&format!("{} {}\n", padding, chars.vbar));
454    }
455}
456
457/// Emitter for HTML output.
458pub struct HtmlEmitter;
459
460impl Emitter for HtmlEmitter {
461    fn render_localized<S: Source + ?Sized, L: Localizer + ?Sized>(&self, source: &S, diagnostic: &Diagnostic, localizer: Option<&L>, uri: Option<&str>) -> String {
462        let mut out = String::new();
463        let line_map = LineMap::from_source(source);
464        let full_text = source.get_text_in(core::range::Range { start: 0, end: source.length() }).into_owned();
465        let lines: Vec<&str> = full_text.lines().collect();
466        let sev_class = match diagnostic.severity {
467            Severity::Error => "error",
468            Severity::Warning => "warning",
469            Severity::Advice => "advice",
470        };
471
472        let message = if let (Some(key), Some(loc)) = (&diagnostic.i18n_key, localizer) { loc.localize(key, &diagnostic.i18n_args) } else { diagnostic.message.clone() };
473
474        out.push_str("<div class=\"diagnostic\">\n");
475        out.push_str(&format!("  <div class=\"header {}\">\n", sev_class));
476        if let Some(code) = &diagnostic.code {
477            out.push_str(&format!("    <span class=\"code\">[{}]</span> <span class=\"message\">{}</span>\n", code, html_escape(&message)));
478        }
479        else {
480            out.push_str(&format!("    <span class=\"severity\">[{}]</span> <span class=\"message\">{}</span>\n", sev_class, html_escape(&message)));
481        }
482        out.push_str("  </div>\n");
483
484        for label in &diagnostic.labels {
485            self.render_snippet(&mut out, source, &line_map, &full_text, &lines, label, uri);
486        }
487
488        if let Some(help) = &diagnostic.help {
489            out.push_str(&format!("  <div class=\"help\">help: {}</div>\n", html_escape(help)));
490        }
491        out.push_str("</div>");
492
493        out
494    }
495}
496
497impl HtmlEmitter {
498    fn render_snippet<S: Source + ?Sized>(&self, out: &mut String, source: &S, line_map: &LineMap, full_text: &str, lines: &[&str], label: &Label, uri: Option<&str>) {
499        let (start_line, _) = line_map.offset_to_line_col_utf16(source, label.span.start);
500        let (end_line, _) = line_map.offset_to_line_col_utf16(source, label.span.end);
501        let start_line = start_line as usize;
502        let end_line = end_line as usize;
503        let start_line_start = line_map.line_start(start_line as u32).unwrap_or(0);
504        let end_line_start = line_map.line_start(end_line as u32).unwrap_or(0);
505        let start_col = full_text.get(start_line_start..label.span.start.min(full_text.len())).unwrap_or("").chars().count();
506        let end_col = full_text.get(end_line_start..label.span.end.min(full_text.len())).unwrap_or("").chars().count();
507
508        out.push_str("  <div class=\"snippet\">\n");
509        let location_prefix = "┌"; // Use the same connector
510        let url_str = uri.unwrap_or("<anonymous>");
511        let pos_str = format!("{}:{}", start_line + 1, start_col + 1);
512        out.push_str(&format!("    <div class=\"location\">  {} at {}:{}</div>\n", location_prefix, html_escape(url_str), pos_str));
513
514        out.push_str("    <pre><code>");
515        let line_num_width = (end_line + 1).to_string().len();
516        let padding = " ".repeat(line_num_width);
517
518        out.push_str(&format!("<span class=\"padding\">{}</span> <span class=\"vbar\">│</span>\n", padding));
519
520        if start_line == end_line {
521            if let Some(line_text) = lines.get(start_line) {
522                out.push_str(&format!("<span class=\"line-num\">{: >width$}</span> <span class=\"vbar\">│</span> {}\n", start_line + 1, html_escape(line_text), width = line_num_width));
523
524                let underline_padding = " ".repeat(start_col);
525                let underline_len = full_text.get(label.span.start.min(full_text.len())..label.span.end.min(full_text.len())).unwrap_or("").chars().count().max(1);
526                let underline = "^".repeat(underline_len);
527                out.push_str(&format!("<span class=\"padding\">{}</span> <span class=\"vbar\">│</span> <span class=\"underline\">{}{}</span>", padding, underline_padding, underline));
528                if let Some(msg) = &label.message {
529                    out.push_str(&format!(" <span class=\"label-msg\">{}</span>", html_escape(msg)));
530                }
531                out.push_str("\n");
532            }
533        }
534        else {
535            for i in start_line..=end_line {
536                if let Some(line_text) = lines.get(i) {
537                    let line_num = i + 1;
538                    if i == start_line {
539                        out.push_str(&format!("<span class=\"line-num\">{: >width$}</span> <span class=\"vbar\">│</span> <span class=\"multiline\">┌</span>{}\n", line_num, html_escape(line_text), width = line_num_width));
540                    }
541                    else if i == end_line {
542                        out.push_str(&format!("<span class=\"line-num\">{: >width$}</span> <span class=\"vbar\">│</span> <span class=\"multiline\">│</span>{}\n", line_num, html_escape(line_text), width = line_num_width));
543
544                        let underline_len = end_col.max(1);
545                        let underline = "^".repeat(underline_len);
546                        out.push_str(&format!("<span class=\"padding\">{}</span> <span class=\"vbar\">│</span> <span class=\"multiline\">└</span><span class=\"underline\">{}</span>", padding, "─".repeat(end_col)));
547                        out.push_str(&format!("<span class=\"underline\">{}</span>", underline));
548                        if let Some(msg) = &label.message {
549                            out.push_str(&format!(" <span class=\"label-msg\">{}</span>", html_escape(msg)));
550                        }
551                        out.push_str("\n");
552                    }
553                    else {
554                        out.push_str(&format!("<span class=\"line-num\">{: >width$}</span> <span class=\"vbar\">│</span> <span class=\"multiline\">│</span>{}\n", line_num, html_escape(line_text), width = line_num_width));
555                    }
556                }
557            }
558        }
559        out.push_str(&format!("<span class=\"padding\">{}</span> <span class=\"vbar\">│</span>\n", padding));
560        out.push_str("</code></pre>\n");
561        out.push_str("  </div>\n");
562    }
563}
564
565fn html_escape(s: &str) -> String {
566    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;").replace('\'', "&#39;")
567}
568
569/// Emitter for LSP (Language Server Protocol) diagnostics.
570#[cfg(feature = "serde")]
571pub struct LspEmitter;
572
573#[cfg(feature = "serde")]
574impl Emitter for LspEmitter {
575    fn render_localized<S: Source + ?Sized, L: Localizer + ?Sized>(&self, source: &S, diagnostic: &Diagnostic, localizer: Option<&L>, uri: Option<&str>) -> String {
576        let line_map = LineMap::from_source(source);
577        let (start_line, start_character, end_line, end_character) = if let Some(label) = diagnostic.labels.first() {
578            let (sl, sc) = line_map.offset_to_line_col_utf16(source, label.span.start);
579            let (el, ec) = line_map.offset_to_line_col_utf16(source, label.span.end);
580            (sl, sc, el, ec)
581        }
582        else {
583            (0, 0, 0, 0)
584        };
585
586        let severity = match diagnostic.severity {
587            Severity::Error => 1,   // Error
588            Severity::Warning => 2, // Warning
589            Severity::Advice => 3,  // Information
590        };
591
592        let message = if let (Some(key), Some(loc)) = (&diagnostic.i18n_key, localizer) { loc.localize(key, &diagnostic.i18n_args) } else { diagnostic.message.clone() };
593
594        let lsp_diag = serde_json::json!({
595            "range": {
596                "start": { "line": start_line, "character": start_character },
597                "end": { "line": end_line, "character": end_character }
598            },
599            "severity": severity,
600            "code": diagnostic.code.clone().unwrap_or_default(),
601            "source": "oak",
602            "message": message,
603            "relatedInformation": diagnostic.labels.iter().filter_map(|l| {
604                l.message.as_ref().map(|msg| {
605                    let (sl, sc) = line_map.offset_to_line_col_utf16(source, l.span.start);
606                    let (el, ec) = line_map.offset_to_line_col_utf16(source, l.span.end);
607                    serde_json::json!({
608                        "location": {
609                            "uri": uri.unwrap_or(""),
610                            "range": {
611                                "start": { "line": sl, "character": sc },
612                                "end": { "line": el, "character": ec }
613                            }
614                        },
615                        "message": msg.clone()
616                    })
617                })
618            }).collect::<Vec<serde_json::Value>>()
619        });
620
621        lsp_diag.to_string()
622    }
623}