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