ad_astra/format/
snippet.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::fmt::{Display, Formatter};
36
37use lady_deirdre::{
38    arena::{Id, Identifiable},
39    format::{AnnotationPriority, SnippetConfig, SnippetFormatter, Style, TerminalString},
40    lexis::{SiteSpan, ToSpan, TokenBuffer},
41};
42
43use crate::{
44    format::highlight::ScriptHighlighter,
45    runtime::PackageMeta,
46    syntax::{ScriptDoc, ScriptToken},
47};
48
49/// A configuration of options for drawing the [ScriptSnippet] object.
50///
51/// The [Default] implementation of this object provides canonical configuration
52/// options.
53#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
54#[non_exhaustive]
55pub struct ScriptSnippetConfig {
56    /// Whether the boxed frame should surround the code content from all sides.
57    ///
58    /// The default value is `true`.
59    pub show_outer_frame: bool,
60
61    /// Whether line numbers should be shown on the left of the code content.
62    ///
63    /// The default value is `true`.
64    pub show_line_numbers: bool,
65
66    /// Whether the full canonical script module path should be shown in the
67    /// caption of the printed snippet.
68    ///
69    /// If set to true, the snippet caption will look like:
70    /// `‹package name›.‹module name› [<custom caption>]`.
71    ///
72    /// Otherwise, the printer will only use the
73    /// [custom caption](ScriptSnippet::set_caption) if specified.
74    ///
75    /// The default value is `true`.
76    pub show_module_path: bool,
77
78    /// If set to true, syntax highlighting will be applied to the source code.
79    /// Otherwise, the source code will be monochrome.
80    ///
81    /// The default value is `true`.
82    pub highlight_code: bool,
83
84    /// Whether the snippet printer should use Unicode
85    /// [box drawing characters](https://en.wikipedia.org/wiki/Box-drawing_characters#Box_Drawing)
86    /// for decorative elements. Otherwise, the printer uses only ASCII box-drawing
87    /// characters.
88    ///
89    /// The default value is `true`.
90    pub unicode_drawing: bool,
91}
92
93impl Default for ScriptSnippetConfig {
94    #[inline(always)]
95    fn default() -> Self {
96        Self::new()
97    }
98}
99
100impl From<ScriptSnippetConfig> for SnippetConfig {
101    #[inline(always)]
102    fn from(value: ScriptSnippetConfig) -> Self {
103        let mut config = Self::verbose();
104
105        config.draw_frame = value.show_outer_frame;
106        config.show_numbers = value.show_line_numbers;
107        config.ascii_drawing = !value.unicode_drawing;
108
109        if !value.highlight_code {
110            config.dim_code = false;
111            config.style = false;
112        }
113
114        config
115    }
116}
117
118impl ScriptSnippetConfig {
119    /// The default constructor for the configuration.
120    #[inline(always)]
121    pub const fn new() -> Self {
122        Self {
123            show_outer_frame: true,
124            show_line_numbers: true,
125            show_module_path: true,
126            highlight_code: true,
127            unicode_drawing: true,
128        }
129    }
130
131    /// The constructor for the configuration object that returns a
132    /// configuration with all advanced drawing options disabled.
133    #[inline(always)]
134    pub const fn minimal() -> Self {
135        Self {
136            show_outer_frame: false,
137            show_line_numbers: false,
138            show_module_path: false,
139            highlight_code: false,
140            unicode_drawing: false,
141        }
142    }
143}
144
145/// A drawing object that renders the source code text of a
146/// [ScriptModule](crate::analysis::ScriptModule) with syntax highlighting and
147/// annotated source code ranges.
148///
149/// The intended use of this object is printing script source code to the
150/// terminal.
151///
152/// ```text
153///    ╭──╢ ‹doctest›.‹my_module.adastra› ╟────────────────────────────────────────╮
154///  1 │                                                                           │
155///  2 │     let foo = 10;                                                         │
156///    │         ╰╴ Annotation text.                                               │
157///  3 │     let bar = foo + 20;                                                   │
158///  4 │                                                                           │
159///    ╰───────────────────────────────────────────────────────────────────────────╯
160/// ```
161///
162/// There are several crate API functions that create this object, such as
163/// [ModuleText::snippet](crate::analysis::ModuleText::snippet) and
164/// [ModuleDiagnostics::highlight](crate::analysis::ModuleDiagnostics::highlight).
165///
166/// The [Display] implementation of this object performs the actual snippet
167/// rendering. For example, you can print the snippet to the terminal using the
168/// `println` macro: `println!("{my_snippet}")`
169pub struct ScriptSnippet<'a> {
170    code: SnippetCode<'a>,
171    config: ScriptSnippetConfig,
172    caption: Option<String>,
173    annotations: Vec<(SiteSpan, AnnotationPriority, String)>,
174    summary: Option<String>,
175}
176
177impl<'a> Display for ScriptSnippet<'a> {
178    #[inline(always)]
179    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
180        let mut caption = String::with_capacity(512);
181
182        match self.config.show_module_path {
183            true => {
184                if let Some(prefix) = &self.caption {
185                    caption.push_str(&prefix.apply(Style::new().bold()));
186                    caption.push_str(" [");
187                }
188
189                let id = match &self.code {
190                    SnippetCode::Borrowed(code) => code.id(),
191                    SnippetCode::Owned(code) => code.id(),
192                };
193
194                caption.push_str(
195                    format_script_path(id, PackageMeta::by_id(id))
196                        .apply(Style::new().bright_cyan())
197                        .as_str(),
198                );
199
200                if self.caption.is_some() {
201                    caption.push(']');
202                }
203            }
204
205            false => {
206                if let Some(prefix) = &self.caption {
207                    caption.push_str(prefix)
208                }
209            }
210        }
211
212        match &self.code {
213            SnippetCode::Borrowed(code) => {
214                let config = self.config.into();
215
216                let mut snippet = formatter.snippet(*code);
217
218                snippet.set_config(&config).set_caption(caption);
219
220                if self.config.highlight_code {
221                    snippet.set_highlighter(ScriptHighlighter::new());
222                }
223
224                if let Some(summary) = &self.summary {
225                    snippet.set_summary(summary.as_str());
226                }
227
228                for (span, priority, message) in &self.annotations {
229                    snippet.annotate(span, *priority, message.as_str());
230                }
231
232                snippet.finish()?;
233            }
234
235            SnippetCode::Owned(code) => {
236                let config = self.config.into();
237
238                let mut snippet = formatter.snippet(code);
239
240                snippet.set_config(&config).set_caption(caption);
241
242                if self.config.highlight_code {
243                    snippet.set_highlighter(ScriptHighlighter::new());
244                }
245
246                if let Some(summary) = &self.summary {
247                    snippet.set_summary(summary.as_str());
248                }
249
250                for (span, priority, message) in &self.annotations {
251                    snippet.annotate(span, *priority, message.as_str());
252                }
253
254                snippet.finish()?;
255            }
256        }
257
258        Ok(())
259    }
260}
261
262impl<S: AsRef<str>> From<S> for ScriptSnippet<'static> {
263    #[inline(always)]
264    fn from(string: S) -> Self {
265        let buffer = TokenBuffer::from(string);
266
267        Self::new(SnippetCode::Owned(buffer))
268    }
269}
270
271impl<'a> ScriptSnippet<'a> {
272    #[inline(always)]
273    fn new(code: SnippetCode<'a>) -> Self {
274        Self {
275            code,
276            config: ScriptSnippetConfig::default(),
277            caption: None,
278            annotations: Vec::new(),
279            summary: None,
280        }
281    }
282
283    #[inline(always)]
284    pub(crate) fn from_doc(doc: &'a ScriptDoc) -> Self {
285        Self::new(SnippetCode::Borrowed(doc))
286    }
287
288    /// Sets the configuration for snippet drawing features.
289    ///
290    /// See [ScriptSnippetConfig] for details.
291    pub fn set_config(&mut self, config: ScriptSnippetConfig) -> &mut Self {
292        self.config = config;
293
294        self
295    }
296
297    /// Sets the caption of the printed content.
298    ///
299    /// The `caption` string will be printed in the header of the snippet.
300    /// By default, the caption is an empty string, and in this case, the
301    /// renderer does not include a custom caption in the header.
302    ///
303    /// The `caption` parameter must be a single-line string. Any additional
304    /// caption lines (separated by the `\n` character) will be ignored.
305    pub fn set_caption(&mut self, caption: impl AsRef<str>) -> &mut Self {
306        self.caption = caption
307            .as_ref()
308            .lines()
309            .next()
310            .map(|line| String::from(line));
311
312        self
313    }
314
315    /// Sets the footer summary text of the printed content.
316    ///
317    /// The `summary` string will be printed below the source code. By default,
318    /// the summary is an empty string, and in this case, the renderer does not
319    /// print any footer text.
320    ///
321    /// Unlike the [caption](Self::set_caption) and
322    /// [annotation](Self::annotate) text, the summary text can have
323    /// multiple lines.
324    pub fn set_summary(&mut self, summary: impl AsRef<str>) -> &mut Self {
325        self.summary = Some(String::from(summary.as_ref()));
326
327        self
328    }
329
330    /// Adds an annotation to the source code.
331    ///
332    /// The `span` argument specifies the source code range intended for
333    /// annotation. You can use a `10..20` absolute Unicode character range, the
334    /// [line-column](lady_deirdre::lexis::Position) range
335    /// `Position::new(10, 3)..Position::new(12, 4)`, or the [ScriptOrigin]
336    /// instance. The span argument must represent a
337    /// [valid](ToSpan::is_valid_span) value (e.g., `20..10` is not a valid
338    /// range because the upper bound is less than the lower bound). Otherwise,
339    /// the annotation will be silently ignored.
340    ///
341    /// The `priority` argument specifies the annotation priority. The snippet
342    /// interface supports the following priority types:
343    ///
344    /// - [AnnotationPriority::Default]: A default annotation. The spanned text
345    ///   will be simply inverted (e.g., white text on a black background).
346    /// - [AnnotationPriority::Primary]: The spanned text will be inverted with
347    ///   a red background.
348    /// - [AnnotationPriority::Secondary]: The spanned text will be inverted
349    ///   with a blue background.
350    /// - [AnnotationPriority::Note]: The spanned text will be inverted with a
351    ///   yellow background.
352    ///
353    /// The `message` argument specifies the text that should label the spanned
354    /// range. The message should be a single-line string. Any additional message
355    /// lines (separated by the `\n` character) will be ignored.
356    ///
357    /// You can leave the message as an empty string. In this case, the renderer
358    /// will not label the spanned text.
359    ///
360    /// Note that if the ScriptSnippet does not have any annotations, the object
361    /// will render the entire source code. Otherwise, the renderer will output
362    /// only the annotated lines plus a few lines of surrounding context.
363    pub fn annotate(
364        &mut self,
365        span: impl ToSpan,
366        priority: AnnotationPriority,
367        message: impl AsRef<str>,
368    ) -> &mut Self {
369        let span = match &self.code {
370            SnippetCode::Borrowed(code) => span.to_site_span(*code),
371            SnippetCode::Owned(code) => span.to_site_span(code),
372        };
373
374        let Some(span) = span else {
375            return self;
376        };
377
378        let message = message
379            .as_ref()
380            .lines()
381            .next()
382            .map(|line| String::from(line))
383            .unwrap_or(String::new());
384
385        self.annotations.push((span, priority, message));
386
387        self
388    }
389}
390
391#[inline(always)]
392pub(crate) fn format_script_path(id: Id, package: Option<&'static PackageMeta>) -> String {
393    let mut path = String::with_capacity(512);
394
395    if let Some(package) = package {
396        path.push_str(&format!("‹{}›.", package));
397    }
398
399    let name = id.name();
400
401    match name.is_empty() {
402        true => path.push_str(&format!("‹#{}›", id.into_inner())),
403        false => path.push_str(&format!("‹{}›", name.escape_debug())),
404    }
405
406    path
407}
408
409enum SnippetCode<'a> {
410    Borrowed(&'a ScriptDoc),
411    Owned(TokenBuffer<ScriptToken>),
412}