Skip to main content

annotate_snippets/
snippet.rs

1//! Structures used as an input for the library.
2
3use alloc::borrow::{Cow, ToOwned};
4use alloc::string::String;
5use alloc::{vec, vec::Vec};
6use core::ops::Range;
7
8use crate::Level;
9use crate::renderer::source_map::{TrimmedPatch, as_substr};
10
11pub(crate) const ERROR_TXT: &str = "error";
12pub(crate) const HELP_TXT: &str = "help";
13pub(crate) const INFO_TXT: &str = "info";
14pub(crate) const NOTE_TXT: &str = "note";
15pub(crate) const WARNING_TXT: &str = "warning";
16
17/// A [diagnostic message][Title] and any associated [context][Element] to help users
18/// understand it
19///
20/// The first [`Group`] is the ["primary" group][Level::primary_title], ie it contains the diagnostic
21/// message.
22///
23/// All subsequent [`Group`]s are for distinct pieces of [context][Level::secondary_title].
24/// The primary group will be visually distinguished to help tell them apart.
25pub type Report<'a> = &'a [Group<'a>];
26
27#[derive(Clone, Debug, Default)]
28pub(crate) struct Id<'a> {
29    pub(crate) id: Option<Cow<'a, str>>,
30    pub(crate) url: Option<Cow<'a, str>>,
31}
32
33/// A [`Title`] with supporting [context][Element] within a [`Report`]
34///
35/// [Decor][crate::renderer::DecorStyle] is used to visually connect [`Element`]s of a `Group`.
36///
37/// Generally, you will create separate group's for:
38/// - New [`Snippet`]s, especially if they need their own [`AnnotationKind::Primary`]
39/// - Each logically distinct set of [suggestions][Patch`]
40///
41/// # Example
42///
43/// ```rust
44/// # #[allow(clippy::needless_doctest_main)]
45#[doc = include_str!("../examples/highlight_message.rs")]
46/// ```
47#[doc = include_str!("../examples/highlight_message.svg")]
48#[derive(Clone, Debug)]
49pub struct Group<'a> {
50    pub(crate) primary_level: Level<'a>,
51    pub(crate) title: Option<Title<'a>>,
52    pub(crate) elements: Vec<Element<'a>>,
53    pub(crate) lineno_offset: usize,
54}
55
56impl<'a> Group<'a> {
57    /// Create group with a [`Title`], deriving [`AnnotationKind::Primary`] from its [`Level`]
58    pub fn with_title(title: Title<'a>) -> Self {
59        let level = title.level.clone();
60        let mut x = Self::with_level(level);
61        x.title = Some(title);
62        x
63    }
64
65    /// Create a title-less group with a primary [`Level`] for [`AnnotationKind::Primary`]
66    ///
67    /// # Example
68    ///
69    /// ```rust
70    /// # #[allow(clippy::needless_doctest_main)]
71    #[doc = include_str!("../examples/elide_header.rs")]
72    /// ```
73    #[doc = include_str!("../examples/elide_header.svg")]
74    pub fn with_level(level: Level<'a>) -> Self {
75        Self {
76            primary_level: level,
77            title: None,
78            elements: vec![],
79            lineno_offset: 0,
80        }
81    }
82
83    /// Append an [`Element`] that adds context to the [`Title`]
84    pub fn element(mut self, section: impl Into<Element<'a>>) -> Self {
85        self.elements.push(section.into());
86        self
87    }
88
89    /// Append [`Element`]s that adds context to the [`Title`]
90    pub fn elements(mut self, sections: impl IntoIterator<Item = impl Into<Element<'a>>>) -> Self {
91        self.elements.extend(sections.into_iter().map(Into::into));
92        self
93    }
94
95    pub fn is_empty(&self) -> bool {
96        self.elements.is_empty() && self.title.is_none()
97    }
98
99    /// Add an offset used for aligning the header sigil (`-->`) with the line number separators.
100    ///
101    /// For normal diagnostics this is computed automatically based on the lines to be rendered.
102    /// This is intended only for use in the formatter, where we don't render a snippet directly but
103    /// still want the header to align with the diff.
104    pub fn lineno_offset(mut self, offset: usize) -> Self {
105        self.lineno_offset = offset;
106        self
107    }
108}
109
110/// A section of content within a [`Group`]
111#[derive(Clone, Debug)]
112#[non_exhaustive]
113pub enum Element<'a> {
114    Message(Message<'a>),
115    Cause(Snippet<'a, Annotation<'a>>),
116    Suggestion(Snippet<'a, Patch<'a>>),
117    Origin(Origin<'a>),
118    Padding(Padding),
119}
120
121impl<'a> From<Message<'a>> for Element<'a> {
122    fn from(value: Message<'a>) -> Self {
123        Element::Message(value)
124    }
125}
126
127impl<'a> From<Snippet<'a, Annotation<'a>>> for Element<'a> {
128    fn from(value: Snippet<'a, Annotation<'a>>) -> Self {
129        Element::Cause(value)
130    }
131}
132
133impl<'a> From<Snippet<'a, Patch<'a>>> for Element<'a> {
134    fn from(value: Snippet<'a, Patch<'a>>) -> Self {
135        Element::Suggestion(value)
136    }
137}
138
139impl<'a> From<Origin<'a>> for Element<'a> {
140    fn from(value: Origin<'a>) -> Self {
141        Element::Origin(value)
142    }
143}
144
145impl From<Padding> for Element<'_> {
146    fn from(value: Padding) -> Self {
147        Self::Padding(value)
148    }
149}
150
151/// A whitespace [`Element`] in a [`Group`]
152#[derive(Clone, Debug)]
153pub struct Padding;
154
155/// A title that introduces a [`Group`], describing the main point
156///
157/// To create a `Title`, see [`Level::primary_title`] or [`Level::secondary_title`].
158///
159/// # Example
160///
161/// ```rust
162/// # use annotate_snippets::*;
163/// let report = &[
164///     Group::with_title(
165///         Level::ERROR.primary_title("mismatched types").id("E0308")
166///     ),
167///     Group::with_title(
168///         Level::HELP.secondary_title("function defined here")
169///     ),
170/// ];
171/// ```
172#[derive(Clone, Debug)]
173pub struct Title<'a> {
174    pub(crate) level: Level<'a>,
175    pub(crate) id: Option<Id<'a>>,
176    pub(crate) text: Cow<'a, str>,
177    pub(crate) allows_styling: bool,
178    pub(crate) is_fixable: bool,
179}
180
181impl<'a> Title<'a> {
182    /// The category for this [`Report`]
183    ///
184    /// Useful for looking searching for more information to resolve the diagnostic.
185    ///
186    /// <div class="warning">
187    ///
188    /// Text passed to this function is considered "untrusted input", as such
189    /// all text is passed through a normalization function. Styled text is
190    /// not allowed to be passed to this function.
191    ///
192    /// </div>
193    pub fn id(mut self, id: impl Into<Cow<'a, str>>) -> Self {
194        self.id.get_or_insert(Id::default()).id = Some(id.into());
195        self
196    }
197
198    /// Provide a URL for [`Title::id`] for more information on this diagnostic
199    ///
200    /// <div class="warning">
201    ///
202    /// This is only relevant if `id` is present
203    ///
204    /// </div>
205    pub fn id_url(mut self, url: impl Into<Cow<'a, str>>) -> Self {
206        self.id.get_or_insert(Id::default()).url = Some(url.into());
207        self
208    }
209
210    /// Append an [`Element`] that adds context to the [`Title`]
211    pub fn element(self, section: impl Into<Element<'a>>) -> Group<'a> {
212        Group::with_title(self).element(section)
213    }
214
215    /// Append [`Element`]s that adds context to the [`Title`]
216    pub fn elements(self, sections: impl IntoIterator<Item = impl Into<Element<'a>>>) -> Group<'a> {
217        Group::with_title(self).elements(sections)
218    }
219
220    /// Whether or not the diagnostic for this message is fixable.
221    ///
222    /// This is rendered as a `[*]` indicator after the `id` in an annotation header, if the
223    /// annotation also has `Level::None`.
224    pub fn is_fixable(mut self, yes: bool) -> Self {
225        self.is_fixable = yes;
226        self
227    }
228}
229
230/// A text [`Element`] in a [`Group`]
231///
232/// See [`Level::message`] to create this.
233#[derive(Clone, Debug)]
234pub struct Message<'a> {
235    pub(crate) level: Level<'a>,
236    pub(crate) text: Cow<'a, str>,
237}
238
239/// A source view [`Element`] in a [`Group`]
240///
241/// If you do not have [source][Snippet::source] available, see instead [`Origin`]
242///
243/// `Snippet`s come in the following styles (`T`):
244/// - With [`Annotation`]s, see [`Snippet::annotation`]
245/// - With [`Patch`]s, see [`Snippet::patch`]
246#[derive(Clone, Debug)]
247pub struct Snippet<'a, T> {
248    pub(crate) path: Option<Cow<'a, str>>,
249    /// The optional cell index in a Jupyter notebook, used for reporting source locations along
250    /// with the ranges on `annotations`.
251    pub(crate) cell_index: Option<usize>,
252    pub(crate) line_start: usize,
253    pub(crate) source: Cow<'a, str>,
254    pub(crate) markers: Vec<T>,
255    pub(crate) fold: bool,
256}
257
258impl<'a, T: Clone> Snippet<'a, T> {
259    /// The source code to be rendered
260    ///
261    /// <div class="warning">
262    ///
263    /// Text passed to this function is considered "untrusted input", as such
264    /// all text is passed through a normalization function. Pre-styled text is
265    /// not allowed to be passed to this function.
266    ///
267    /// </div>
268    pub fn source(source: impl Into<Cow<'a, str>>) -> Self {
269        Self {
270            path: None,
271            line_start: 1,
272            cell_index: None,
273            source: source.into(),
274            markers: vec![],
275            fold: true,
276        }
277    }
278
279    /// When manually [`fold`][Self::fold]ing,
280    /// the [`source`][Self::source]s line offset from the original start
281    pub fn line_start(mut self, line_start: usize) -> Self {
282        self.line_start = line_start;
283        self
284    }
285
286    /// The location of the [`source`][Self::source] (e.g. a path)
287    ///
288    /// <div class="warning">
289    ///
290    /// Text passed to this function is considered "untrusted input", as such
291    /// all text is passed through a normalization function. Pre-styled text is
292    /// not allowed to be passed to this function.
293    ///
294    /// </div>
295    pub fn path(mut self, path: impl Into<OptionCow<'a>>) -> Self {
296        self.path = path.into().0;
297        self
298    }
299
300    /// Attach a Jupyter notebook cell index.
301    pub fn cell_index(mut self, index: Option<usize>) -> Self {
302        self.cell_index = index;
303        self
304    }
305
306    /// Control whether lines without [`Annotation`]s are shown
307    ///
308    /// The default is `fold(true)`, collapsing uninteresting lines.
309    ///
310    /// See [`AnnotationKind::Visible`] to force specific spans to be shown.
311    pub fn fold(mut self, fold: bool) -> Self {
312        self.fold = fold;
313        self
314    }
315}
316
317impl<'a> Snippet<'a, Annotation<'a>> {
318    /// Highlight and describe a span of text within the [`source`][Self::source]
319    pub fn annotation(mut self, annotation: Annotation<'a>) -> Snippet<'a, Annotation<'a>> {
320        self.markers.push(annotation);
321        self
322    }
323
324    /// Highlight and describe spans of text within the [`source`][Self::source]
325    pub fn annotations(mut self, annotation: impl IntoIterator<Item = Annotation<'a>>) -> Self {
326        self.markers.extend(annotation);
327        self
328    }
329}
330
331impl<'a> Snippet<'a, Patch<'a>> {
332    /// Suggest to the user an edit to the [`source`][Self::source]
333    pub fn patch(mut self, patch: Patch<'a>) -> Snippet<'a, Patch<'a>> {
334        self.markers.push(patch);
335        self
336    }
337
338    /// Suggest to the user edits to the [`source`][Self::source]
339    pub fn patches(mut self, patches: impl IntoIterator<Item = Patch<'a>>) -> Self {
340        self.markers.extend(patches);
341        self
342    }
343}
344
345/// Highlight and describe a span of text within a [`Snippet`]
346///
347/// See [`AnnotationKind`] to create an annotation.
348///
349/// # Example
350///
351/// ```rust
352/// # #[allow(clippy::needless_doctest_main)]
353#[doc = include_str!("../examples/expected_type.rs")]
354/// ```
355///
356#[doc = include_str!("../examples/expected_type.svg")]
357#[derive(Clone, Debug)]
358pub struct Annotation<'a> {
359    pub(crate) span: Range<usize>,
360    pub(crate) label: Option<Cow<'a, str>>,
361    pub(crate) kind: AnnotationKind,
362    pub(crate) highlight_source: bool,
363    pub(crate) is_file_level: bool,
364}
365
366impl<'a> Annotation<'a> {
367    /// Describe the reason the span is highlighted
368    ///
369    /// This will be styled according to the [`AnnotationKind`]
370    ///
371    /// <div class="warning">
372    ///
373    /// Text passed to this function is considered "untrusted input", as such
374    /// all text is passed through a normalization function. Pre-styled text is
375    /// not allowed to be passed to this function.
376    ///
377    /// </div>
378    pub fn label(mut self, label: impl Into<OptionCow<'a>>) -> Self {
379        self.label = label.into().0;
380        self
381    }
382
383    /// Style the source according to the [`AnnotationKind`]
384    ///
385    /// This gives extra emphasis to this annotation
386    pub fn highlight_source(mut self, highlight_source: bool) -> Self {
387        self.highlight_source = highlight_source;
388        self
389    }
390
391    pub fn hide_snippet(mut self, yes: bool) -> Self {
392        self.is_file_level = yes;
393        self
394    }
395}
396
397/// The type of [`Annotation`] being applied to a [`Snippet`]
398#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
399#[non_exhaustive]
400pub enum AnnotationKind {
401    /// For showing the source that the [Group's Title][Group::with_title] references
402    ///
403    /// For [`Title`]-less groups, see [`Group::with_level`]
404    Primary,
405    /// Additional context to better understand the [`Primary`][Self::Primary]
406    /// [`Annotation`]
407    ///
408    /// See also [`Renderer::context`].
409    ///
410    /// [`Renderer::context`]: crate::renderer::Renderer
411    Context,
412    /// Prevents the annotated text from getting [folded][Snippet::fold]
413    ///
414    /// By default, [`Snippet`]s will [fold][`Snippet::fold`] (remove) lines
415    /// that do not contain any annotations. [`Visible`][Self::Visible] makes
416    /// it possible to selectively prevent this behavior for specific text,
417    /// allowing context to be preserved without adding any annotation
418    /// characters.
419    ///
420    /// # Example
421    ///
422    /// ```rust
423    /// # #[allow(clippy::needless_doctest_main)]
424    #[doc = include_str!("../examples/struct_name_as_context.rs")]
425    /// ```
426    ///
427    #[doc = include_str!("../examples/struct_name_as_context.svg")]
428    ///
429    Visible,
430}
431
432impl AnnotationKind {
433    /// Annotate a byte span within [`Snippet`]
434    pub fn span<'a>(self, span: Range<usize>) -> Annotation<'a> {
435        Annotation {
436            span,
437            label: None,
438            kind: self,
439            highlight_source: false,
440            is_file_level: false,
441        }
442    }
443
444    pub(crate) fn is_primary(&self) -> bool {
445        matches!(self, AnnotationKind::Primary)
446    }
447}
448
449/// Suggested edit to the [`Snippet`]
450///
451/// See [`Snippet::patch`]
452///
453/// # Example
454///
455/// ```rust
456/// # #[allow(clippy::needless_doctest_main)]
457#[doc = include_str!("../examples/multi_suggestion.rs")]
458/// ```
459///
460#[doc = include_str!("../examples/multi_suggestion.svg")]
461#[derive(Clone, Debug)]
462pub struct Patch<'a> {
463    pub(crate) span: Range<usize>,
464    pub(crate) replacement: Cow<'a, str>,
465}
466
467impl<'a> Patch<'a> {
468    /// Splice `replacement` into the [`Snippet`] at the specified byte span
469    ///
470    /// <div class="warning">
471    ///
472    /// Text passed to this function is considered "untrusted input", as such
473    /// all text is passed through a normalization function. Pre-styled text is
474    /// not allowed to be passed to this function.
475    ///
476    /// </div>
477    pub fn new(span: Range<usize>, replacement: impl Into<Cow<'a, str>>) -> Self {
478        Self {
479            span,
480            replacement: replacement.into(),
481        }
482    }
483
484    /// Try to turn a replacement into an addition when the span that is being
485    /// overwritten matches either the prefix or suffix of the replacement.
486    pub(crate) fn trim_trivial_replacements(self, source: &str) -> TrimmedPatch<'a> {
487        let mut trimmed = TrimmedPatch {
488            original_span: self.span.clone(),
489            span: self.span,
490            replacement: self.replacement,
491        };
492
493        if trimmed.replacement.is_empty() {
494            return trimmed;
495        }
496        let Some(snippet) = source.get(trimmed.original_span.clone()) else {
497            return trimmed;
498        };
499
500        if let Some((prefix, substr, suffix)) = as_substr(snippet, &trimmed.replacement) {
501            trimmed.span = trimmed.original_span.start + prefix
502                ..trimmed.original_span.end.saturating_sub(suffix);
503            trimmed.replacement = Cow::Owned(substr.to_owned());
504        }
505        trimmed
506    }
507}
508
509/// A source location [`Element`] in a [`Group`]
510///
511/// If you have source available, see instead [`Snippet`]
512///
513/// # Example
514///
515/// ```rust
516/// # use annotate_snippets::{Group, Snippet, AnnotationKind, Level, Origin};
517/// let report = &[
518///     Level::ERROR.primary_title("mismatched types").id("E0308")
519///         .element(
520///             Origin::path("$DIR/mismatched-types.rs")
521///         )
522/// ];
523/// ```
524#[derive(Clone, Debug)]
525pub struct Origin<'a> {
526    pub(crate) path: Option<Cow<'a, str>>,
527    /// The optional cell index in a Jupyter notebook, used for reporting source locations along
528    /// with the ranges on `annotations`.
529    pub(crate) cell_index: Option<usize>,
530    pub(crate) line: Option<usize>,
531    pub(crate) char_column: Option<usize>,
532}
533
534impl<'a> Origin<'a> {
535    /// <div class="warning">
536    ///
537    /// Text passed to this function is considered "untrusted input", as such
538    /// all text is passed through a normalization function. Pre-styled text is
539    /// not allowed to be passed to this function.
540    ///
541    /// </div>
542    pub fn path(path: impl Into<Cow<'a, str>>) -> Self {
543        Self {
544            path: Some(path.into()),
545            cell_index: None,
546            line: None,
547            char_column: None,
548        }
549    }
550
551    /// Attach a Jupyter notebook cell index.
552    pub fn cell_index(mut self, index: Option<usize>) -> Self {
553        self.cell_index = index;
554        self
555    }
556
557    /// Set the default line number to display
558    pub fn line(mut self, line: usize) -> Self {
559        self.line = Some(line);
560        self
561    }
562
563    /// Set the default column to display
564    ///
565    /// <div class="warning">
566    ///
567    /// `char_column` is only be respected if [`Origin::line`] is also set.
568    ///
569    /// </div>
570    pub fn char_column(mut self, char_column: usize) -> Self {
571        self.char_column = Some(char_column);
572        self
573    }
574}
575
576impl<'a> From<Cow<'a, str>> for Origin<'a> {
577    fn from(origin: Cow<'a, str>) -> Self {
578        Self::path(origin)
579    }
580}
581
582#[derive(Debug)]
583pub struct OptionCow<'a>(pub(crate) Option<Cow<'a, str>>);
584
585impl<'a, T: Into<Cow<'a, str>>> From<Option<T>> for OptionCow<'a> {
586    fn from(value: Option<T>) -> Self {
587        Self(value.map(Into::into))
588    }
589}
590
591impl<'a> From<&'a Cow<'a, str>> for OptionCow<'a> {
592    fn from(value: &'a Cow<'a, str>) -> Self {
593        Self(Some(Cow::Borrowed(value)))
594    }
595}
596
597impl<'a> From<Cow<'a, str>> for OptionCow<'a> {
598    fn from(value: Cow<'a, str>) -> Self {
599        Self(Some(value))
600    }
601}
602
603impl<'a> From<&'a str> for OptionCow<'a> {
604    fn from(value: &'a str) -> Self {
605        Self(Some(Cow::Borrowed(value)))
606    }
607}
608impl<'a> From<String> for OptionCow<'a> {
609    fn from(value: String) -> Self {
610        Self(Some(Cow::Owned(value)))
611    }
612}
613
614impl<'a> From<&'a String> for OptionCow<'a> {
615    fn from(value: &'a String) -> Self {
616        Self(Some(Cow::Borrowed(value.as_str())))
617    }
618}