ad_astra/analysis/
diagnostics.rs

1////////////////////////////////////////////////////////////////////////////////
2// This file is part of "Ad Astra", an embeddable scripting programming       //
3// language platform.                                                         //
4//                                                                            //
5// This work is proprietary software with source-available code.              //
6//                                                                            //
7// To copy, use, distribute, or contribute to this work, you must agree to    //
8// the terms of the General License Agreement:                                //
9//                                                                            //
10// https://github.com/Eliah-Lakhin/ad-astra/blob/master/EULA.md               //
11//                                                                            //
12// The agreement grants a Basic Commercial License, allowing you to use       //
13// this work in non-commercial and limited commercial products with a total   //
14// gross revenue cap. To remove this commercial limit for one of your         //
15// products, you must acquire a Full Commercial License.                      //
16//                                                                            //
17// If you contribute to the source code, documentation, or related materials, //
18// you must grant me an exclusive license to these contributions.             //
19// Contributions are governed by the "Contributions" section of the General   //
20// License Agreement.                                                         //
21//                                                                            //
22// Copying the work in parts is strictly forbidden, except as permitted       //
23// under the General License Agreement.                                       //
24//                                                                            //
25// If you do not or cannot agree to the terms of this Agreement,              //
26// do not use this work.                                                      //
27//                                                                            //
28// This work is provided "as is", without any warranties, express or implied, //
29// except where such disclaimers are legally invalid.                         //
30//                                                                            //
31// Copyright (c) 2024 Ilya Lakhin (Илья Александрович Лахин).                 //
32// All rights reserved.                                                       //
33////////////////////////////////////////////////////////////////////////////////
34
35use std::{collections::hash_set::Iter, iter::FusedIterator, ops::Deref};
36
37use ahash::AHashSet;
38use lady_deirdre::{
39    analysis::Revision,
40    arena::{Id, Identifiable},
41    sync::Shared,
42};
43
44use crate::{
45    analysis::{IssueCode, IssueSeverity, ModuleText, ScriptIssue},
46    format::ScriptSnippet,
47    runtime::ScriptOrigin,
48};
49
50/// A level indicating the depth of diagnostic analysis.
51///
52/// Each level represents an independent collection of issues related to the
53/// script module. Currently, Ad Astra supports the following levels:
54///
55/// - Level `1`: All syntax parse errors. If the script module has no errors
56///   at this level, the source code is syntactically well-formed. However, it
57///   may still contain semantic errors or warnings.
58/// - Level `2`: All semantic errors and warnings that can be inferred directly
59///   from the local syntax context, without requiring deep source code
60///   analysis.
61/// - Level `3`: All warnings that require deep semantic analysis of the
62///   interconnections between source code constructs.
63///
64/// Issues at lower diagnostic levels are more critical for the end user,
65/// while issues at higher levels require more computational resources to infer.
66///
67/// Therefore, if a script module contains syntax errors (level `1`), you may
68/// choose to display only these errors in the terminal, postponing deeper
69/// diagnostic analysis until the end user addresses the issues at the lower
70/// levels.
71
72pub type DiagnosticsDepth = u8;
73
74/// A collection of diagnostic issues (errors and warnings) in the script
75/// module's source code.
76///
77/// Created by the [diagnostics](crate::analysis::ModuleRead::diagnostics)
78/// function.
79#[derive(Clone)]
80pub struct ModuleDiagnostics {
81    pub(super) id: Id,
82    pub(super) issues: Shared<AHashSet<ScriptIssue>>,
83    pub(super) depth: DiagnosticsDepth,
84    pub(super) revision: Revision,
85}
86
87impl Identifiable for ModuleDiagnostics {
88    #[inline(always)]
89    fn id(&self) -> Id {
90        self.id
91    }
92}
93
94impl<'a> IntoIterator for &'a ModuleDiagnostics {
95    type Item = ModuleIssue<'a>;
96    type IntoIter = DiagnosticsIter<'a>;
97
98    #[inline(always)]
99    fn into_iter(self) -> Self::IntoIter {
100        self.iter()
101    }
102}
103
104impl ModuleDiagnostics {
105    /// Returns the number of issues in this collection that match the specified
106    /// issue mask (`severity_mask`).
107    ///
108    /// For example, `len(IssueSeverity::Error as u8)` returns the number of
109    /// errors, while `len(!0)` returns the total number of issues, including
110    /// both errors and warnings.
111    #[inline(always)]
112    pub fn len(&self, severity_mask: u8) -> usize {
113        let errors = severity_mask & (IssueSeverity::Error as u8) > 0;
114        let warnings = severity_mask & (IssueSeverity::Warning as u8) > 0;
115
116        match (errors, warnings) {
117            (false, false) => 0,
118
119            (true, false) => self
120                .iter()
121                .filter(|error| error.severity() == IssueSeverity::Error)
122                .count(),
123
124            (false, true) => self
125                .iter()
126                .filter(|error| error.severity() == IssueSeverity::Warning)
127                .count(),
128
129            (true, true) => self.issues.as_ref().len(),
130        }
131    }
132
133    /// Returns true if this collection does not contain any diagnostic issues.
134    #[inline(always)]
135    pub fn is_empty(&self) -> bool {
136        self.issues.as_ref().is_empty()
137    }
138
139    /// Returns the diagnostic analysis depth at which this collection was
140    /// constructed.
141    ///
142    /// See [DiagnosticsDepth] for details.
143    #[inline(always)]
144    pub fn depth(&self) -> DiagnosticsDepth {
145        self.depth
146    }
147
148    /// Returns the revision number at which this collection was constructed.
149    ///
150    /// For a specific script module instance and [DiagnosticsDepth], this
151    /// number always increases and never decreases.
152    ///
153    /// If two collections (of the same script module and the same diagnostic
154    /// depth) have the same revision number, their content can be considered
155    /// identical.
156    ///
157    /// However, if one collection has a higher revision number than the
158    /// previous one, it indicates that the diagnostics at this level of depth
159    /// have been updated.
160    #[inline(always)]
161    pub fn revision(&self) -> Revision {
162        self.revision
163    }
164
165    /// Returns an iterator that yields references to each diagnostic issue
166    /// (error or warning) in this diagnostics collection.
167    ///
168    /// The issues are returned in an unspecified order. If you want to print
169    /// each issue manually, you may consider sorting them (e.g., by issue type
170    /// or by their position in the source code).
171    #[inline(always)]
172    pub fn iter(&self) -> DiagnosticsIter {
173        DiagnosticsIter {
174            id: self.id,
175            inner: self.issues.as_ref().iter(),
176        }
177    }
178
179    /// Returns a [script snippet](ScriptSnippet) that highlights source code
180    /// fragments associated with the underlying issues and annotates them with
181    /// diagnostic messages.
182    ///
183    /// This function provides an easy way to print all diagnostic issues at
184    /// once to the terminal.
185    ///
186    /// To construct the returned snippet object, you need access to the
187    /// module's text, which can be obtained using the
188    /// [text](crate::analysis::ModuleRead::text) function.
189    ///
190    /// The `severity_mask` allows you to filter issues by their severity:
191    /// `IssueSeverity::Error as u8` shows only error issues, while `!0` shows
192    /// both error and warning issues.
193    ///
194    /// ## Example
195    ///
196    /// ```rust
197    /// # use ad_astra::{
198    /// #     analysis::{ModuleRead, ScriptModule},
199    /// #     export,
200    /// #     lady_deirdre::analysis::TriggerHandle,
201    /// #     runtime::ScriptPackage,
202    /// # };
203    /// #
204    /// # #[export(package)]
205    /// # #[derive(Default)]
206    /// # struct Package;
207    /// #
208    /// let module = ScriptModule::new(Package::meta(), "let foo = ; let = 10;");
209    /// module.rename("my_module.adastra");
210    ///
211    /// let handle = TriggerHandle::new();
212    /// let module_read = module.read(&handle, 1).unwrap();
213    ///
214    /// let diagnostics = module_read.diagnostics(1).unwrap();
215    /// let text = module_read.text();
216    ///
217    /// println!("{}", diagnostics.highlight(&text, !0));
218    /// ```
219    ///
220    /// Outputs:
221    ///
222    /// ```text
223    ///    ╭──╢ diagnostics [‹doctest›.‹my_module.adastra›] ╟──────────────────────────╮
224    ///  1 │ let foo = ; let = 10;                                                     │
225    ///    │          │     ╰╴ missing var name in 'let <var> = <expr>;'               │
226    ///    │          ╰╴ missing expression in 'let <var> = <expr>;'                   │
227    ///    ├───────────────────────────────────────────────────────────────────────────┤
228    ///    │ Errors: 2                                                                 │
229    ///    │ Warnings: 0                                                               │
230    ///    ╰───────────────────────────────────────────────────────────────────────────╯
231    /// ```
232    pub fn highlight<'a>(&self, text: &'a ModuleText, severity_mask: u8) -> ScriptSnippet<'a> {
233        let mut snippet = text.snippet();
234
235        snippet.set_caption("diagnostics");
236
237        let include_errors = severity_mask & (IssueSeverity::Error as u8) > 0;
238        let include_warnings = severity_mask & (IssueSeverity::Warning as u8) > 0;
239
240        let mut total_errors = 0;
241        let mut total_warnings = 0;
242        #[allow(unused)]
243        let mut annotations = 0;
244
245        for issue in self.iter() {
246            match issue.severity() {
247                IssueSeverity::Error => {
248                    total_errors += 1;
249
250                    if !include_errors {
251                        continue;
252                    }
253                }
254
255                IssueSeverity::Warning => {
256                    total_warnings += 1;
257
258                    if !include_warnings {
259                        continue;
260                    }
261                }
262            }
263
264            annotations += 1;
265
266            snippet.annotate(
267                issue.origin(text),
268                issue.severity().priority(),
269                issue.verbose_message(text),
270            );
271        }
272
273        let mut summary = String::with_capacity(1024);
274
275        match total_errors == 0 && total_warnings == 0 {
276            true => summary.push_str("No issues detected."),
277
278            false => {
279                summary.push_str(&format!("Errors: {}", total_errors));
280
281                if !include_errors {
282                    summary.push_str(" (omitted).");
283                }
284
285                summary.push('\n');
286
287                summary.push_str(&format!("Warnings: {}", total_warnings));
288
289                if !include_warnings {
290                    summary.push_str(" (omitted).");
291                }
292            }
293        };
294
295        snippet.set_summary(summary);
296
297        snippet
298    }
299}
300
301/// An iterator over the diagnostic issues in the [ModuleDiagnostics]
302/// collection.
303///
304/// Created by the [ModuleDiagnostics::iter] function and the [IntoIterator]
305/// implementation of the ModuleDiagnostics.
306pub struct DiagnosticsIter<'a> {
307    id: Id,
308    inner: Iter<'a, ScriptIssue>,
309}
310
311impl<'a> Iterator for DiagnosticsIter<'a> {
312    type Item = ModuleIssue<'a>;
313
314    #[inline(always)]
315    fn next(&mut self) -> Option<Self::Item> {
316        let issue = self.inner.next()?;
317
318        Some(ModuleIssue { id: self.id, issue })
319    }
320
321    #[inline(always)]
322    fn size_hint(&self) -> (usize, Option<usize>) {
323        self.inner.size_hint()
324    }
325}
326
327impl<'a> FusedIterator for DiagnosticsIter<'a> {}
328
329impl<'a> ExactSizeIterator for DiagnosticsIter<'a> {
330    #[inline(always)]
331    fn len(&self) -> usize {
332        self.inner.len()
333    }
334}
335
336/// An individual diagnostic issue in the [ModuleDiagnostics] collection.
337///
338/// Created by the [DiagnosticsIter] iterator, it represents a view into the
339/// issue object owned by the collection.
340pub struct ModuleIssue<'a> {
341    id: Id,
342    issue: &'a ScriptIssue,
343}
344
345impl<'a> Identifiable for ModuleIssue<'a> {
346    #[inline(always)]
347    fn id(&self) -> Id {
348        self.id
349    }
350}
351
352impl<'a> ModuleIssue<'a> {
353    /// Returns an [IssueCode] object that describes the class of issues to
354    /// which this issue belongs.
355    ///
356    /// From this IssueCode object, you can obtain additional metadata about
357    /// this class, or convert the class into a numeric code value.
358    #[inline(always)]
359    pub fn code(&self) -> IssueCode {
360        self.issue.code()
361    }
362
363    /// Indicates whether this issue is a hard error or a warning.
364    ///
365    /// Equivalent to `issue.code().severity()`.
366    #[inline(always)]
367    pub fn severity(&self) -> IssueSeverity {
368        self.code().severity()
369    }
370
371    /// Returns a short description of the class of issues to which this issue
372    /// belongs.
373    ///
374    /// Equivalent to `issue.code().to_string()`.
375    #[inline(always)]
376    pub fn short_message(&self) -> String {
377        self.code().to_string()
378    }
379
380    /// Returns an issue-specific text message.
381    ///
382    /// Unlike [ModuleIssue::short_message], this message considers the full
383    /// context of the issue and its relation to the source code. Both
384    /// [ModuleDiagnostics::highlight] and [ModuleIssue::highlight] use this
385    /// text when annotating the source code snippet.
386    #[inline(always)]
387    pub fn verbose_message(&self, text: &ModuleText) -> String {
388        self.issue.message(text.doc_read.deref()).to_string()
389    }
390
391    /// Returns a reference to the source code fragment where the issue appears.
392    ///
393    /// You can use the
394    /// [to_site_span](lady_deirdre::lexis::ToSpan::to_site_span) function to
395    /// convert the returned object into an absolute character index range, or
396    /// [to_position_span](lady_deirdre::lexis::ToSpan::to_position_span) to
397    /// convert it into a line-column range.
398    #[inline(always)]
399    pub fn origin(&self, text: &ModuleText) -> ScriptOrigin {
400        self.issue.span(text.doc_read.deref())
401    }
402
403    /// Returns a quick-fix suggestion that could potentially resolve the
404    /// underlying issue.
405    ///
406    /// Some diagnostic issues can be addressed directly based on heuristic
407    /// assumptions. For example, if the user misspells a variable name, the
408    /// [IssueQuickfix] might suggest a replacement for the identifier.
409    ///
410    /// If the function returns `None`, it means there is no quick fix for this
411    /// issue that can currently be inferred heuristically. Future versions of
412    /// Ad Astra may provide more quick-fix options.
413    ///
414    /// This function is primarily useful for code editor extensions, such as
415    /// refactoring/quick-fix actions.
416    pub fn quickfix(&self) -> Option<IssueQuickfix> {
417        match self.issue {
418            ScriptIssue::UnresolvedPackage { quickfix, .. } if !quickfix.is_empty() => {
419                Some(IssueQuickfix {
420                    set_text_to_origin: Some(quickfix.to_string()),
421                    implement_use_of: None,
422                })
423            }
424
425            ScriptIssue::UnresolvedIdent {
426                quickfix, import, ..
427            } if !quickfix.is_empty() || !import.is_empty() => Some(IssueQuickfix {
428                set_text_to_origin: (!quickfix.is_empty()).then(|| quickfix.to_string()),
429                implement_use_of: (!import.is_empty()).then(|| import.to_string()),
430            }),
431
432            ScriptIssue::UnknownComponent { quickfix, .. } if !quickfix.is_empty() => {
433                Some(IssueQuickfix {
434                    set_text_to_origin: Some(quickfix.to_string()),
435                    implement_use_of: None,
436                })
437            }
438
439            _ => None,
440        }
441    }
442
443    /// Returns a [script snippet](ScriptSnippet) that highlights a source code
444    /// fragment related to the underlying issue, annotating it with the
445    /// diagnostic message.
446    ///
447    /// This function is similar to [ModuleDiagnostics::highlight], but it
448    /// highlights only a single issue and does not include a footer with
449    /// summary metadata.
450    #[inline(always)]
451    pub fn highlight<'b>(&self, text: &'b ModuleText) -> ScriptSnippet<'b> {
452        let mut snippet = text.snippet();
453
454        snippet.set_caption(self.severity().to_string()).annotate(
455            self.origin(text),
456            self.severity().priority(),
457            self.verbose_message(text),
458        );
459
460        snippet
461    }
462
463    /// Returns a numeric value indicating the level of diagnostic analysis
464    /// depth to which this issue belongs.
465    ///
466    /// Equivalent to `issue.code().depth()`.
467    ///
468    /// See [DiagnosticsDepth] for more details.
469    #[inline(always)]
470    pub fn depth(&self) -> DiagnosticsDepth {
471        self.code().depth()
472    }
473}
474
475/// A heuristic suggestion that could potentially fix a module diagnostics
476/// issue.
477///
478/// Created by the [ModuleIssue::quickfix] function (see the function's
479/// documentation for details).
480///
481/// To fully implement this suggestion, each struct field should be addressed.
482#[derive(Default, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
483pub struct IssueQuickfix {
484    /// To resolve this issue, the [ModuleIssue::origin] fragment of the source
485    /// code must be replaced with this text.
486    pub set_text_to_origin: Option<String>,
487
488    /// To resolve this issue, the `use <implement_use_of>;` import statement
489    /// must be added to the source code.
490    pub implement_use_of: Option<String>,
491}