Skip to main content

i_slint_compiler/
diagnostics.rs

1// Copyright © SixtyFPS GmbH <info@slint.dev>
2// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
3
4use std::io::Read;
5use std::path::{Path, PathBuf};
6use std::rc::Rc;
7
8use crate::parser::TextSize;
9use std::collections::BTreeSet;
10
11/// Span represent an error location within a file.
12///
13/// Currently, it is just an offset in byte within the file + the corresponding length.
14///
15/// When the `proc_macro_span` feature is enabled, it may also hold a proc_macro span.
16#[derive(Debug, Clone)]
17pub struct Span {
18    pub offset: usize,
19    pub length: usize,
20    #[cfg(feature = "proc_macro_span")]
21    pub span: Option<proc_macro::Span>,
22}
23
24impl Span {
25    pub fn is_valid(&self) -> bool {
26        self.offset != usize::MAX
27    }
28
29    #[allow(clippy::needless_update)] // needed when `proc_macro_span` is enabled
30    pub fn new(offset: usize, length: usize) -> Self {
31        Self { offset, length, ..Default::default() }
32    }
33}
34
35impl Default for Span {
36    fn default() -> Self {
37        Span {
38            offset: usize::MAX,
39            length: 0,
40            #[cfg(feature = "proc_macro_span")]
41            span: Default::default(),
42        }
43    }
44}
45
46impl PartialEq for Span {
47    fn eq(&self, other: &Span) -> bool {
48        self.offset == other.offset && self.length == other.length
49    }
50}
51
52#[cfg(feature = "proc_macro_span")]
53impl From<proc_macro::Span> for Span {
54    fn from(span: proc_macro::Span) -> Self {
55        Self { span: Some(span), ..Default::default() }
56    }
57}
58
59/// Returns a span.  This is implemented for tokens and nodes
60pub trait Spanned {
61    fn span(&self) -> Span;
62    fn source_file(&self) -> Option<&SourceFile>;
63    fn to_source_location(&self) -> SourceLocation {
64        SourceLocation { source_file: self.source_file().cloned(), span: self.span() }
65    }
66}
67
68#[derive(Default)]
69pub struct SourceFileInner {
70    path: PathBuf,
71
72    /// Complete source code of the path, used to map from offset to line number
73    source: Option<String>,
74
75    /// The offset of each linebreak
76    line_offsets: std::cell::OnceCell<Vec<usize>>,
77}
78
79impl std::fmt::Debug for SourceFileInner {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        write!(f, "{:?}", self.path)
82    }
83}
84
85impl SourceFileInner {
86    pub fn new(path: PathBuf, source: String) -> Self {
87        Self { path, source: Some(source), line_offsets: Default::default() }
88    }
89
90    pub fn path(&self) -> &Path {
91        &self.path
92    }
93
94    /// Create a SourceFile that has just a path, but no contents
95    pub fn from_path_only(path: PathBuf) -> Rc<Self> {
96        Rc::new(Self { path, ..Default::default() })
97    }
98
99    /// Returns a tuple with the line (starting at 1) and column number (starting at 1)
100    pub fn line_column(&self, offset: usize, format: ByteFormat) -> (usize, usize) {
101        let adjust_utf16 = |line_begin, col| {
102            if format == ByteFormat::Utf16
103                && let Some(source) = &self.source
104            {
105                return i_slint_common::unicode_utils::byte_offset_to_utf16_offset(
106                    &source[line_begin..],
107                    col,
108                );
109            }
110            col
111        };
112
113        let line_offsets = self.line_offsets();
114        line_offsets.binary_search(&offset).map_or_else(
115            |line| {
116                if line == 0 {
117                    (1, adjust_utf16(0, offset) + 1)
118                } else {
119                    let line_begin = *line_offsets.get(line - 1).unwrap_or(&0);
120                    (line + 1, adjust_utf16(line_begin, offset - line_begin) + 1)
121                }
122            },
123            |line| (line + 2, 1),
124        )
125    }
126
127    pub fn text_size_to_file_line_column(
128        &self,
129        size: TextSize,
130        format: ByteFormat,
131    ) -> (String, usize, usize, usize, usize) {
132        let file_name = self.path().to_string_lossy().to_string();
133        let (start_line, start_column) = self.line_column(size.into(), format);
134        (file_name, start_line, start_column, start_line, start_column)
135    }
136
137    /// Returns the offset that corresponds to the line/column
138    pub fn offset(&self, line: usize, column: usize, format: ByteFormat) -> usize {
139        let adjust_utf16 = |line_begin, col| {
140            if format == ByteFormat::Utf16
141                && let Some(source) = &self.source
142            {
143                return i_slint_common::unicode_utils::utf16_offset_to_byte_offset_clamped(
144                    &source[line_begin..],
145                    col,
146                );
147            }
148            col
149        };
150
151        let col_offset = column.saturating_sub(1);
152        if line <= 1 {
153            // line == 0 is actually invalid!
154            return adjust_utf16(0, col_offset);
155        }
156        let offsets = self.line_offsets();
157        let index = std::cmp::min(line.saturating_sub(1), offsets.len());
158        let line_offset = *offsets.get(index.saturating_sub(1)).unwrap_or(&0);
159        line_offset.saturating_add(adjust_utf16(line_offset, col_offset))
160    }
161
162    fn line_offsets(&self) -> &[usize] {
163        self.line_offsets.get_or_init(|| {
164            self.source
165                .as_ref()
166                .map(|s| {
167                    s.bytes()
168                        .enumerate()
169                        // Add the offset one past the '\n' into the index: That's the first char
170                        // of the new line!
171                        .filter_map(|(i, c)| if c == b'\n' { Some(i + 1) } else { None })
172                        .collect()
173                })
174                .unwrap_or_default()
175        })
176    }
177
178    pub fn source(&self) -> Option<&str> {
179        self.source.as_deref()
180    }
181}
182
183#[derive(Copy, Clone, Eq, PartialEq, Debug)]
184/// When converting between line/columns to offset, specify if the format of the column is UTF-8 or UTF-16
185pub enum ByteFormat {
186    Utf8,
187    Utf16,
188}
189
190pub type SourceFile = Rc<SourceFileInner>;
191
192pub fn load_from_path(path: &Path) -> Result<String, Diagnostic> {
193    let string = (if path == Path::new("-") {
194        let mut buffer = Vec::new();
195        let r = std::io::stdin().read_to_end(&mut buffer);
196        r.and_then(|_| {
197            String::from_utf8(buffer)
198                .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))
199        })
200    } else {
201        std::fs::read_to_string(path)
202    })
203    .map_err(|err| Diagnostic {
204        message: format!("Could not load {}: {}", path.display(), err),
205        span: SourceLocation {
206            source_file: Some(SourceFileInner::from_path_only(path.to_owned())),
207            span: Default::default(),
208        },
209        level: DiagnosticLevel::Error,
210    })?;
211
212    if path.extension().is_some_and(|e| e == "rs") {
213        return crate::lexer::extract_rust_macro(string).ok_or_else(|| Diagnostic {
214            message: "No `slint!` macro".into(),
215            span: SourceLocation {
216                source_file: Some(SourceFileInner::from_path_only(path.to_owned())),
217                span: Default::default(),
218            },
219            level: DiagnosticLevel::Error,
220        });
221    }
222
223    Ok(string)
224}
225
226#[derive(Debug, Clone, Default)]
227pub struct SourceLocation {
228    pub source_file: Option<SourceFile>,
229    pub span: Span,
230}
231
232impl Spanned for SourceLocation {
233    fn span(&self) -> Span {
234        self.span.clone()
235    }
236
237    fn source_file(&self) -> Option<&SourceFile> {
238        self.source_file.as_ref()
239    }
240}
241
242impl Spanned for Option<SourceLocation> {
243    fn span(&self) -> crate::diagnostics::Span {
244        self.as_ref().map(|n| n.span()).unwrap_or_default()
245    }
246
247    fn source_file(&self) -> Option<&SourceFile> {
248        self.as_ref().map(|n| n.source_file.as_ref()).unwrap_or_default()
249    }
250}
251
252/// This enum describes the level or severity of a diagnostic message produced by the compiler.
253#[derive(Debug, PartialEq, Copy, Clone, Default)]
254#[non_exhaustive]
255pub enum DiagnosticLevel {
256    /// The diagnostic found is an error that prevents successful compilation.
257    #[default]
258    Error,
259    /// The diagnostic found is a warning.
260    Warning,
261    /// The diagnostic is an note to further help with the error or warning
262    Note,
263}
264
265/// This structure represent a diagnostic emitted while compiling .slint code.
266///
267/// It is basically a message, a level (warning or error), attached to a
268/// position in the code
269#[derive(Debug, Clone)]
270pub struct Diagnostic {
271    message: String,
272    span: SourceLocation,
273    level: DiagnosticLevel,
274}
275
276//NOTE! Diagnostic is re-exported in the public API of the interpreter
277impl Diagnostic {
278    /// Return the level for this diagnostic
279    pub fn level(&self) -> DiagnosticLevel {
280        self.level
281    }
282
283    /// Return a message for this diagnostic
284    pub fn message(&self) -> &str {
285        &self.message
286    }
287
288    /// Returns a tuple with the line (starting at 1) and column number (starting at 1)
289    ///
290    /// Can also return (0, 0) if the span is invalid
291    pub fn line_column(&self) -> (usize, usize) {
292        if !self.span.span.is_valid() {
293            return (0, 0);
294        }
295        let offset = self.span.span.offset;
296
297        match &self.span.source_file {
298            None => (0, 0),
299            Some(sl) => sl.line_column(offset, ByteFormat::Utf8),
300        }
301    }
302
303    /// Return the length of this diagnostic in UTF-8 encoded bytes.
304    pub fn length(&self) -> usize {
305        self.span.span.length
306    }
307
308    // NOTE: The return-type differs from the Spanned trait.
309    // Because this is public API (Diagnostic is re-exported by the Interpreter), we cannot change
310    // this.
311    /// return the path of the source file where this error is attached
312    pub fn source_file(&self) -> Option<&Path> {
313        self.span.source_file().map(|sf| sf.path())
314    }
315}
316
317impl std::fmt::Display for Diagnostic {
318    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
319        if let Some(sf) = self.span.source_file() {
320            let (line, _) = self.line_column();
321            write!(f, "{}:{}: {}", sf.path.display(), line, self.message)
322        } else {
323            write!(f, "{}", self.message)
324        }
325    }
326}
327
328impl std::fmt::Display for SourceLocation {
329    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
330        if let Some(sf) = &self.source_file {
331            let (line, col) = sf.line_column(self.span.offset, ByteFormat::Utf8);
332            write!(f, "{}:{line}:{col}", sf.path.display())
333        } else {
334            write!(f, "<unknown>")
335        }
336    }
337}
338
339pub fn diagnostic_line_column_with_format(
340    diagnostic: &Diagnostic,
341    format: ByteFormat,
342) -> (usize, usize) {
343    let Some(sf) = &diagnostic.span.source_file else { return (0, 0) };
344    sf.line_column(diagnostic.span.span.offset, format)
345}
346
347pub fn diagnostic_end_line_column_with_format(
348    diagnostic: &Diagnostic,
349    format: ByteFormat,
350) -> (usize, usize) {
351    let Some(sf) = &diagnostic.span.source_file else { return (0, 0) };
352    // The end_line_column is exclusive.
353    // Even if the span indicates a length of 0, the diagnostic should always
354    // return an end_line_column that is at least one offset further.
355    // Diagnostic::length ensures this.
356    let offset = diagnostic.span.span.offset + diagnostic.length();
357    sf.line_column(offset, format)
358}
359
360#[derive(Default)]
361pub struct BuildDiagnostics {
362    inner: Vec<Diagnostic>,
363
364    /// When false, throw error for experimental features
365    pub enable_experimental: bool,
366
367    /// When true, reject features not supported by the safety-critical subset
368    #[cfg(feature = "slint-sc")]
369    pub slint_sc: bool,
370
371    /// This is the list of all loaded files (with or without diagnostic)
372    /// does not include the main file.
373    /// FIXME: this doesn't really belong in the diagnostics, it should be somehow returned in another way
374    /// (maybe in a compilation state that include the diagnostics?)
375    pub all_loaded_files: BTreeSet<PathBuf>,
376}
377
378impl IntoIterator for BuildDiagnostics {
379    type Item = Diagnostic;
380    type IntoIter = <Vec<Diagnostic> as IntoIterator>::IntoIter;
381    fn into_iter(self) -> Self::IntoIter {
382        self.inner.into_iter()
383    }
384}
385
386impl BuildDiagnostics {
387    pub fn push_diagnostic_with_span(
388        &mut self,
389        message: String,
390        span: SourceLocation,
391        level: DiagnosticLevel,
392    ) {
393        debug_assert!(
394            !message.as_str().ends_with('.'),
395            "Error message should not end with a period: ({message:?})"
396        );
397        self.inner.push(Diagnostic { message, span, level });
398    }
399    pub fn push_error_with_span(&mut self, message: String, span: SourceLocation) {
400        self.push_diagnostic_with_span(message, span, DiagnosticLevel::Error)
401    }
402    pub fn push_error(&mut self, message: String, source: &dyn Spanned) {
403        self.push_error_with_span(message, source.to_source_location());
404    }
405    pub fn push_warning_with_span(&mut self, message: String, span: SourceLocation) {
406        self.push_diagnostic_with_span(message, span, DiagnosticLevel::Warning)
407    }
408    pub fn push_warning(&mut self, message: String, source: &dyn Spanned) {
409        self.push_warning_with_span(message, source.to_source_location());
410    }
411    pub fn push_note_with_span(&mut self, message: String, span: SourceLocation) {
412        self.push_diagnostic_with_span(message, span, DiagnosticLevel::Note)
413    }
414    pub fn push_note(&mut self, message: String, source: &dyn Spanned) {
415        self.push_note_with_span(message, source.to_source_location());
416    }
417    pub fn push_compiler_error(&mut self, error: Diagnostic) {
418        self.inner.push(error);
419    }
420
421    /// If in safety-critical mode, push an error saying that `feature` is not
422    /// supported.
423    ///
424    /// Errors are suppressed for builtin files (paths starting with `builtin:`)
425    /// since those are loaded automatically by the compiler and are not user code.
426    #[cfg(feature = "slint-sc")]
427    pub fn slint_sc_error(&mut self, feature: &str, source: &dyn Spanned) {
428        if self.slint_sc
429            && !source
430                .source_file()
431                .is_some_and(|sf| sf.path().to_string_lossy().starts_with("builtin:"))
432        {
433            self.push_error(format!("{feature} not supported in Slint SC"), source);
434        }
435    }
436
437    pub fn push_property_deprecation_warning(
438        &mut self,
439        old_property: &str,
440        new_property: &str,
441        source: &dyn Spanned,
442    ) {
443        self.push_diagnostic_with_span(
444            format!(
445                "The property '{old_property}' has been deprecated. Please use '{new_property}' instead"
446            ),
447            source.to_source_location(),
448            crate::diagnostics::DiagnosticLevel::Warning,
449        )
450    }
451
452    /// Return true if there is at least one compilation error for this file
453    pub fn has_errors(&self) -> bool {
454        self.inner.iter().any(|diag| diag.level == DiagnosticLevel::Error)
455    }
456
457    /// Return true if there are no diagnostics (warnings or errors); false otherwise.
458    pub fn is_empty(&self) -> bool {
459        self.inner.is_empty()
460    }
461
462    #[cfg(feature = "display-diagnostics")]
463    fn call_diagnostics(
464        &self,
465        mut handle_no_source: Option<&mut dyn FnMut(&Diagnostic)>,
466    ) -> String {
467        if self.inner.is_empty() {
468            return Default::default();
469        }
470
471        let report: Vec<_> = self
472            .inner
473            .iter()
474            .filter_map(|d| {
475                let annotate_snippets_level = match d.level {
476                    DiagnosticLevel::Error => annotate_snippets::Level::ERROR,
477                    DiagnosticLevel::Warning => annotate_snippets::Level::WARNING,
478                    DiagnosticLevel::Note => annotate_snippets::Level::NOTE,
479                };
480                let message = annotate_snippets_level.primary_title(d.message());
481
482                let group = if !d.span.span.is_valid() {
483                    annotate_snippets::Group::with_title(message)
484                } else if let Some(sf) = &d.span.source_file {
485                    if let Some(source) = &sf.source {
486                        let start_offset = d.span.span.offset;
487                        let end_offset = d.span.span.offset + d.length();
488                        message.element(
489                            annotate_snippets::Snippet::source(source)
490                                .path(sf.path.to_string_lossy())
491                                .annotation(
492                                    annotate_snippets::AnnotationKind::Primary
493                                        .span(start_offset..end_offset),
494                                ),
495                        )
496                    } else {
497                        if let Some(ref mut handle_no_source) = handle_no_source {
498                            drop(message);
499                            handle_no_source(d);
500                            return None;
501                        }
502                        message.element(annotate_snippets::Origin::path(sf.path.to_string_lossy()))
503                    }
504                } else {
505                    annotate_snippets::Group::with_title(message)
506                };
507                Some(group)
508            })
509            .collect();
510
511        annotate_snippets::Renderer::styled().render(&report)
512    }
513
514    #[cfg(feature = "display-diagnostics")]
515    /// Print the diagnostics on the console
516    pub fn print(self) {
517        use std::io::Write;
518        let to_print = self.call_diagnostics(None);
519        if !to_print.is_empty() {
520            let _ = writeln!(std::io::stderr(), "{to_print}");
521        }
522    }
523
524    #[cfg(feature = "display-diagnostics")]
525    /// Print into a string
526    pub fn diagnostics_as_string(self) -> String {
527        self.call_diagnostics(None)
528    }
529
530    #[cfg(all(feature = "proc_macro_span", feature = "display-diagnostics"))]
531    /// Will convert the diagnostics that only have offsets to the actual proc_macro::Span
532    pub fn report_macro_diagnostic(
533        self,
534        span_map: &[crate::parser::Token],
535    ) -> proc_macro::TokenStream {
536        let mut result = proc_macro::TokenStream::default();
537        let mut needs_error = self.has_errors();
538        let output = self.call_diagnostics(
539            Some(&mut |diag| {
540                let span = diag.span.span.span.or_else(|| {
541                    //let pos =
542                    //span_map.binary_search_by_key(d.span.offset, |x| x.0).unwrap_or_else(|x| x);
543                    //d.span.span = span_map.get(pos).as_ref().map(|x| x.1);
544                    let mut offset = 0;
545                    span_map.iter().find_map(|t| {
546                        if diag.span.span.offset <= offset {
547                            t.span
548                        } else {
549                            offset += t.text.len();
550                            None
551                        }
552                    })
553                });
554                let message = &diag.message;
555
556                let span: proc_macro2::Span = if let Some(span) = span {
557                    span.into()
558                } else {
559                    proc_macro2::Span::call_site()
560                };
561                match diag.level {
562                    DiagnosticLevel::Error => {
563                        needs_error = false;
564                        result.extend(proc_macro::TokenStream::from(
565                            quote::quote_spanned!(span => compile_error!{ #message })
566                        ));
567                    }
568                    DiagnosticLevel::Warning => {
569                        result.extend(proc_macro::TokenStream::from(
570                            quote::quote_spanned!(span => const _ : () = { #[deprecated(note = #message)] const WARNING: () = (); WARNING };)
571                        ));
572                    },
573                    DiagnosticLevel::Note => {
574                        // TODO: Notes are not (yet) supported in proc-macros, we'll just print them as warnings for now.
575                        // We can fix this once proc-macro diagnostics support notes
576                        let message = format!("note: {message}");
577                        result.extend(proc_macro::TokenStream::from(
578                            quote::quote_spanned!(span => const _ : () = { #[deprecated(note = #message)] const NOTE: () = (); NOTE };)
579                        ));
580                    },
581                }
582            }),
583        );
584        if !output.is_empty() {
585            eprintln!("{output}");
586        }
587
588        if needs_error {
589            result.extend(proc_macro::TokenStream::from(quote::quote!(
590                compile_error! { "Error occurred" }
591            )))
592        }
593        result
594    }
595
596    pub fn to_string_vec(&self) -> Vec<String> {
597        self.inner.iter().map(|d| d.to_string()).collect()
598    }
599
600    pub fn push_diagnostic(
601        &mut self,
602        message: String,
603        source: &dyn Spanned,
604        level: DiagnosticLevel,
605    ) {
606        self.push_diagnostic_with_span(message, source.to_source_location(), level)
607    }
608
609    pub fn push_internal_error(&mut self, err: Diagnostic) {
610        self.inner.push(err)
611    }
612
613    pub fn iter(&self) -> impl Iterator<Item = &Diagnostic> {
614        self.inner.iter()
615    }
616
617    #[cfg(feature = "display-diagnostics")]
618    #[must_use]
619    pub fn check_and_exit_on_error(self) -> Self {
620        if self.has_errors() {
621            self.print();
622            std::process::exit(-1);
623        }
624        self
625    }
626
627    #[cfg(feature = "display-diagnostics")]
628    pub fn print_warnings_and_exit_on_error(self) {
629        let has_error = self.has_errors();
630        self.print();
631        if has_error {
632            std::process::exit(-1);
633        }
634    }
635}
636
637#[cfg(test)]
638mod tests {
639    use super::*;
640
641    #[test]
642    fn test_source_file_offset_line_column_mapping() {
643        let content = r#"import { LineEdit, Button, Slider, HorizontalBox, VerticalBox } from "std-widgets.slint";
644
645component MainWindow inherits Window {
646    property <duration> total-time: slider.value * 1s;
647
648    callback tick(duration);
649    VerticalBox {
650        HorizontalBox {
651            padding-left: 0;
652            Text { text: "Elapsed Time:"; }
653            Rectangle {
654                Rectangle {
655                    height: 100%;
656                    background: lightblue;
657                }
658            }
659        }
660    }
661
662
663}
664
665
666    "#.to_string();
667        let sf = SourceFileInner::new(PathBuf::from("foo.slint"), content.clone());
668
669        let mut line = 1;
670        let mut column = 1;
671        for offset in 0..content.len() {
672            let b = *content.as_bytes().get(offset).unwrap();
673
674            assert_eq!(sf.offset(line, column, ByteFormat::Utf8), offset);
675            assert_eq!(sf.line_column(offset, ByteFormat::Utf8), (line, column));
676
677            if b == b'\n' {
678                line += 1;
679                column = 1;
680            } else {
681                column += 1;
682            }
683        }
684    }
685}