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}