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