Skip to main content

yara_x/compiler/
report.rs

1use std::borrow::Cow;
2use std::cell::Cell;
3use std::collections::HashMap;
4use std::fmt::{Debug, Display, Formatter};
5use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard};
6
7use annotate_snippets::renderer::{AnsiColor, Color, DEFAULT_TERM_WIDTH};
8use annotate_snippets::{AnnotationKind, Group, Snippet, renderer};
9use serde::ser::SerializeStruct;
10use serde::{Serialize, Serializer};
11
12use yara_x_parser::Span;
13
14use crate::SourceCode;
15
16pub type Level = annotate_snippets::Level<'static>;
17
18/// Identifier for each source code file registered in a [`ReportBuilder`].
19/// Each source file gets assigned its own unique `SourceId` when registered
20/// via [`ReportBuilder::register_source`].
21#[derive(Hash, Eq, PartialEq, Clone, Copy, Debug, Default)]
22pub struct SourceId(u32);
23
24/// A `CodeLoc` points to a fragment of source code.
25///
26/// It consists of a [`SourceId`] and a [`Span`], where the former identifies
27/// the source file, and the latter a span of text within that source file.
28///
29/// The [`SourceId`] is optional, if it is [`None`] it means that the [`Span`]
30/// is relative to the current source file.
31#[derive(PartialEq, Debug, Clone, Eq, Default)]
32pub struct CodeLoc {
33    source_id: Option<SourceId>,
34    span: Span,
35}
36
37impl CodeLoc {
38    pub(crate) fn new(source_id: Option<SourceId>, span: Span) -> Self {
39        Self { source_id, span }
40    }
41}
42
43/// A patch that be applied for fixing a warning or error.
44pub struct Patch {
45    code_cache: Arc<CodeCache>,
46    code_loc: CodeLoc,
47    replacement: String,
48}
49
50impl Patch {
51    /// Origin of the source code, as specified by [`SourceCode::with_origin`].
52    pub fn origin(&self) -> Option<String> {
53        self.code_cache
54            .read()
55            .get(&self.code_loc.source_id.unwrap())
56            .unwrap()
57            .origin
58            .clone()
59    }
60
61    /// Span covering the portion of source code that needs to be replaced.
62    pub fn span(&self) -> Span {
63        self.code_loc.span.clone()
64    }
65
66    /// The new code that should replace the original one indicated by
67    /// [`Patch::span`].
68    pub fn replacement(&self) -> &str {
69        &self.replacement
70    }
71}
72
73/// Represents an error or warning report.
74///
75/// This structure represents the message displayed to the user when an error
76/// or warning occurs. It implements the [`Display`] trait, ensuring that when
77/// printed, it reflects the standard error format used by YARA-X. For example:
78///
79/// ```text
80/// error[E006]: unexpected negative number
81///  --> line:6:12
82///   |
83/// 6 |     $a in (-1..0)
84///   |            ^^ this number can not be negative
85///   |
86/// ```
87///
88/// In addition to generating the report, this type provides access to the
89/// individual components of the report, which include:
90///
91/// - `level`: Indicates the severity, either `Level::Error` or `Level::Warning`.
92/// - `code`: A unique code that identifies the specific error or warning
93///   (e.g., "E006").
94/// - `title`: The title of the report (e.g., "unexpected negative number").
95/// - `labels`: A collection of labels included in the report. Each label
96///   contains a level, a span, and associated text.
97/// - `footers`: A collection notes that appear after the end of the report.
98#[derive(Clone)]
99pub(crate) struct Report {
100    code_cache: Arc<CodeCache>,
101    with_colors: bool,
102    max_width: usize,
103    level: Level,
104    code: &'static str,
105    title: String,
106    labels: Vec<(Level, CodeLoc, String)>,
107    footers: Vec<(Level, String)>,
108    sections: Vec<Section>,
109}
110
111#[derive(Clone)]
112pub(crate) struct Section {
113    level: Level,
114    title: String,
115    patches: Vec<(CodeLoc, String)>,
116}
117
118impl Report {
119    /// Returns the report's title.
120    #[inline]
121    pub(crate) fn title(&self) -> &str {
122        self.title.as_str()
123    }
124
125    /// Returns the report's labels.
126    pub(crate) fn labels(&self) -> impl Iterator<Item = Label<'_>> {
127        self.labels.iter().map(|(level, code_loc, text)| {
128            let source_id =
129                code_loc.source_id.expect("CodeLoc without source ID");
130
131            let code_cache = self.code_cache.read();
132            let cache_entry = code_cache.get(&source_id).unwrap();
133            let span = code_loc.span.clone();
134
135            let (line, column) = match cache_entry
136                .byte_offset_to_line_col(span.start())
137            {
138                Some((line, column)) => (line, column),
139                None => panic!(
140                    "can't find line and column for span {span} in code:\n{}",
141                    &cache_entry.code
142                ),
143            };
144
145            Label {
146                level: level_as_text(level),
147                code_origin: cache_entry.origin.clone(),
148                line,
149                column,
150                span,
151                text,
152            }
153        })
154    }
155
156    /// Returns the report's footers.
157    #[inline]
158    pub(crate) fn footers(&self) -> impl Iterator<Item = Footer<'_>> {
159        self.footers
160            .iter()
161            .map(|(level, text)| Footer { level: level_as_text(level), text })
162    }
163
164    /// Returns all the patches in the report.
165    pub(crate) fn patches(&self) -> impl Iterator<Item = Patch> + use<'_> {
166        self.sections.iter().flat_map(|section| {
167            section.patches.iter().map(|(code_loc, replacement)| Patch {
168                code_cache: self.code_cache.clone(),
169                code_loc: code_loc.clone(),
170                replacement: replacement.clone(),
171            })
172        })
173    }
174
175    pub(crate) fn new_section<T: Into<String>>(
176        &mut self,
177        level: Level,
178        title: T,
179    ) -> &mut Self {
180        self.sections.push(Section {
181            level,
182            title: title.into(),
183            patches: vec![],
184        });
185        self
186    }
187
188    pub(crate) fn patch<R: Into<String>>(
189        &mut self,
190        code_loc: CodeLoc,
191        replacement: R,
192    ) -> &mut Self {
193        if self.sections.is_empty() {
194            self.new_section(Level::HELP, "consider the following change");
195        };
196        self.sections
197            .last_mut()
198            .unwrap()
199            .patches
200            .push((code_loc, replacement.into()));
201        self
202    }
203}
204
205impl Serialize for Report {
206    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
207    where
208        S: Serializer,
209    {
210        let labels = self.labels().collect::<Vec<_>>();
211        let footers = &self.footers().collect::<Vec<_>>();
212
213        let mut s = serializer.serialize_struct("report", 4)?;
214
215        s.serialize_field("code", &self.code)?;
216        s.serialize_field("title", &self.title)?;
217
218        // Find the first label with the same level as the report itself.
219        // The report's line and column will be the line and column of
220        // that label.
221        if let Some(label) = labels
222            .iter()
223            .find(|label| label.level == level_as_text(&self.level))
224        {
225            s.serialize_field("line", &label.line)?;
226            s.serialize_field("column", &label.column)?;
227        }
228
229        s.serialize_field("labels", &labels)?;
230        s.serialize_field("footers", &footers)?;
231        s.serialize_field("text", &self.to_string())?;
232        s.end()
233    }
234}
235
236impl PartialEq for Report {
237    fn eq(&self, other: &Self) -> bool {
238        self.level.eq(&other.level)
239            && self.code.eq(other.code)
240            && self.title.eq(&other.title)
241            && self.labels.eq(&other.labels)
242            && self.footers.eq(&other.footers)
243    }
244}
245
246impl Eq for Report {}
247
248impl Debug for Report {
249    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
250        write!(f, "{self}")
251    }
252}
253
254impl Display for Report {
255    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
256        let code_cache = self.code_cache.read();
257
258        let mut group = Group::with_title(
259            self.level.clone().primary_title(&self.title).id(self.code),
260        );
261
262        let mut source_ids = Vec::new();
263        for (_, label_ref, _) in &self.labels {
264            let sid = label_ref.source_id.unwrap();
265            if !source_ids.contains(&sid) {
266                source_ids.push(sid);
267            }
268        }
269
270        for source_id in source_ids {
271            let cache_entry = code_cache.get(&source_id).unwrap();
272
273            // To optimize snippet rendering for large files, we avoid passing
274            // the entire source code to annotate_snippets. Instead, we find
275            // the minimum and maximum byte offsets across all labels in this
276            // source file, locate their enclosing lines, and slice only that
277            // minimal section of code.
278            let min_offset = self
279                .labels
280                .iter()
281                .filter(|l| l.1.source_id.unwrap() == source_id)
282                .map(|l| l.1.span.start())
283                .min()
284                .unwrap();
285
286            let max_offset = self
287                .labels
288                .iter()
289                .filter(|l| l.1.source_id.unwrap() == source_id)
290                .map(|l| l.1.span.end())
291                .max()
292                .unwrap();
293
294            let (sliced_src, line_start, slice_start) =
295                get_source_slice(cache_entry, min_offset, max_offset);
296
297            // Construct snippet with the sliced source and explicitly align
298            // the starting line number.
299            let mut snippet = Snippet::source(sliced_src)
300                .line_start(line_start)
301                .path(cache_entry.origin.as_deref().unwrap_or("line"));
302
303            for (level, label_ref, label) in &self.labels {
304                if label_ref.source_id.unwrap() == source_id {
305                    let annotation_kind = if matches!(level, &Level::ERROR) {
306                        AnnotationKind::Primary
307                    } else {
308                        AnnotationKind::Context
309                    };
310
311                    // Shift annotation spans relative to the start
312                    // of our sliced source code.
313                    let span_start =
314                        label_ref.span.start().saturating_sub(slice_start);
315                    let span_end =
316                        label_ref.span.end().saturating_sub(slice_start);
317
318                    snippet = snippet.annotation(
319                        annotation_kind
320                            .span(span_start..span_end)
321                            .label(label),
322                    );
323                }
324            }
325
326            group = group.element(snippet);
327        }
328
329        for (level, text) in &self.footers {
330            group = group.element(level.clone().message(text.as_str()));
331        }
332
333        let renderer = if self.with_colors {
334            annotate_snippets::Renderer::styled()
335        } else {
336            annotate_snippets::Renderer::plain()
337        };
338
339        let renderer = renderer.term_width(self.max_width);
340
341        let mut groups = vec![group];
342
343        for section in &self.sections {
344            if section.patches.is_empty() {
345                continue;
346            }
347            let sid = section.patches[0].0.source_id.unwrap();
348            let cache_entry = code_cache.get(&sid).unwrap();
349
350            // Similarly, slice the source code around the minimum and maximum
351            // patch offsets.
352            let min_offset = section
353                .patches
354                .iter()
355                .map(|(loc, _)| loc.span.start())
356                .min()
357                .unwrap();
358            let max_offset = section
359                .patches
360                .iter()
361                .map(|(loc, _)| loc.span.end())
362                .max()
363                .unwrap();
364
365            let (sliced_src, line_start, slice_start) =
366                get_source_slice(cache_entry, min_offset, max_offset);
367
368            let mut snippet = Snippet::source(sliced_src)
369                .line_start(line_start)
370                .path(cache_entry.origin.as_deref().unwrap_or("line"));
371
372            for (code_loc, replacement) in &section.patches {
373                // Shift patch spans relative to the sliced source code.
374                let span_start =
375                    code_loc.span.start().saturating_sub(slice_start);
376                let span_end = code_loc.span.end().saturating_sub(slice_start);
377
378                snippet = snippet.patch(annotate_snippets::Patch::new(
379                    span_start..span_end,
380                    replacement,
381                ))
382            }
383
384            groups.push(
385                section
386                    .level
387                    .clone()
388                    .secondary_title(&section.title)
389                    .element(snippet),
390            );
391        }
392
393        let text = renderer.render(&groups);
394
395        write!(f, "{text}")
396    }
397}
398
399/// Given a cache entry and byte offset range, returns the minimal source code
400/// slice, its 1-based starting line number, and starting byte offset.
401fn get_source_slice(
402    cache_entry: &CodeCacheEntry,
403    min_offset: usize,
404    max_offset: usize,
405) -> (&str, usize, usize) {
406    let line_starts = &cache_entry.line_starts;
407    let start_line_idx =
408        line_starts.partition_point(|&x| x <= min_offset).saturating_sub(1);
409    let end_line_idx =
410        line_starts.partition_point(|&x| x <= max_offset).saturating_sub(1);
411
412    let slice_start = line_starts[start_line_idx];
413    let slice_end = if end_line_idx + 1 < line_starts.len() {
414        line_starts[end_line_idx + 1]
415    } else {
416        cache_entry.code.len()
417    };
418
419    (
420        &cache_entry.code[slice_start..slice_end],
421        start_line_idx + 1,
422        slice_start,
423    )
424}
425
426/// Represents a label in an error or warning report.
427#[derive(Serialize)]
428pub struct Label<'a> {
429    level: &'a str,
430    code_origin: Option<String>,
431    line: usize,
432    column: usize,
433    span: Span,
434    text: &'a str,
435}
436
437impl Label<'_> {
438    #[inline]
439    pub fn origin(&self) -> Option<&str> {
440        self.code_origin.as_deref()
441    }
442
443    #[inline]
444    pub fn span(&self) -> &Span {
445        &self.span
446    }
447
448    #[inline]
449    pub fn text(&self) -> &str {
450        self.text
451    }
452}
453
454/// Represents a footer in an error or warning report.
455#[derive(Serialize)]
456pub struct Footer<'a> {
457    level: &'a str,
458    text: &'a str,
459}
460
461/// Builds error and warning reports.
462///
463/// `ReportBuilder` helps to create error and warning reports. It stores a copy
464/// of every source file registered with [register_source], and then allows
465/// creating error reports with annotated code snippets obtained from those
466/// source files.
467///
468/// [register_source]: ReportBuilder::register_source
469pub struct ReportBuilder {
470    with_colors: bool,
471    max_width: usize,
472    current_source_id: Cell<Option<SourceId>>,
473    next_source_id: Cell<SourceId>,
474    code_cache: Arc<CodeCache>,
475}
476
477/// A cache containing source files registered in a [`ReportBuilder`].
478struct CodeCache {
479    data: RwLock<HashMap<SourceId, CodeCacheEntry>>,
480}
481
482impl CodeCache {
483    fn new() -> Self {
484        Self { data: RwLock::new(HashMap::new()) }
485    }
486
487    pub fn read(
488        &self,
489    ) -> RwLockReadGuard<'_, HashMap<SourceId, CodeCacheEntry>> {
490        self.data.read().unwrap()
491    }
492
493    pub fn write(
494        &self,
495    ) -> RwLockWriteGuard<'_, HashMap<SourceId, CodeCacheEntry>> {
496        self.data.write().unwrap()
497    }
498}
499
500/// Each of the entries stored in [`CodeCache`].
501struct CodeCacheEntry {
502    code: String,
503    line_starts: Vec<usize>,
504    origin: Option<String>,
505}
506
507impl CodeCacheEntry {
508    /// Given a position indicated as a byte offset, returns the same position
509    /// as a (line, column) pair.
510    fn byte_offset_to_line_col(
511        &self,
512        byte_offset: usize,
513    ) -> Option<(usize, usize)> {
514        if byte_offset > self.code.len()
515            || !self.code.is_char_boundary(byte_offset)
516        {
517            return None;
518        }
519
520        let line = self.line_starts.partition_point(|&x| x <= byte_offset);
521        let line_start = self.line_starts[line - 1];
522        let col = self.code[line_start..byte_offset].chars().count() + 1;
523
524        Some((line, col))
525    }
526}
527
528impl Default for ReportBuilder {
529    fn default() -> Self {
530        Self::new()
531    }
532}
533
534impl ReportBuilder {
535    /// Creates a new instance of [`ReportBuilder`].
536    pub fn new() -> Self {
537        Self {
538            with_colors: false,
539            max_width: DEFAULT_TERM_WIDTH,
540            current_source_id: Cell::new(None),
541            next_source_id: Cell::new(SourceId(0)),
542            code_cache: Arc::new(CodeCache::new()),
543        }
544    }
545
546    /// Indicates whether the reports should have colors. By default, this is
547    /// `false`.
548    pub fn with_colors(&mut self, yes: bool) -> &mut Self {
549        self.with_colors = yes;
550        self
551    }
552
553    /// Sets the maximum number of columns while rendering error messages.
554    ///
555    /// The default value is 140.
556    pub fn max_width(&mut self, width: usize) -> &mut Self {
557        self.max_width = width;
558        self
559    }
560
561    /// Returns the current [`SourceId`].
562    ///
563    /// This is the [`SourceId`] for the most recently registered source code,
564    /// or the most recent call to [`ReportBuilder::set_current_source_id`].
565    pub fn get_current_source_id(&self) -> Option<SourceId> {
566        self.current_source_id.get()
567    }
568
569    /// Sets the current [`SourceId`] to the given one.
570    pub fn set_current_source_id(&mut self, source_id: SourceId) {
571        self.current_source_id.set(Some(source_id));
572    }
573
574    /// Converts a span to a [`CodeLoc`] using the current source ID.
575    ///
576    /// This is a convenience method that creates a [`CodeLoc`] with the current
577    /// source ID and the provided span.
578    pub fn span_to_code_loc(&self, span: Span) -> CodeLoc {
579        CodeLoc::new(self.get_current_source_id(), span)
580    }
581
582    /// Returns the green style used in error/warning reports.
583    ///
584    /// This is an example of how to use it:
585    ///
586    /// ```text
587    /// let style = report_builder.green_style();
588    /// format!("lorem ipsum {style}dolor sit amet{style:#}");
589    /// ```
590    ///
591    /// In the example above "dolor sit amet" will be painted in green, except
592    /// if colors are disabled.
593    pub fn green_style(&self) -> renderer::Style {
594        if self.with_colors {
595            renderer::Style::new()
596                .fg_color(Some(Color::Ansi(AnsiColor::BrightGreen)))
597        } else {
598            renderer::Style::new()
599        }
600    }
601
602    /// Registers a source code with the report builder.
603    ///
604    /// Before calling [`ReportBuilder::create_report`] for creating error
605    /// reports, the source code containing the error must be registered
606    /// using this function.
607    ///
608    /// This function allows code that is not valid UTF-8, in such cases it
609    /// replaces the invalid characters with the UTF-8 replacement character.
610    ///
611    /// The function returns a [`SourceID`] that identifies the registered
612    /// source code. The current source ID is also set to this ID.
613    pub fn register_source(&self, src: &SourceCode) -> SourceId {
614        let source_id = self.next_source_id.get();
615        self.next_source_id.set(SourceId(source_id.0 + 1));
616        self.current_source_id.set(Some(source_id));
617
618        self.code_cache.write().entry(source_id).or_insert_with(|| {
619            let s = if let Some(s) = src.valid {
620                Cow::Borrowed(s)
621            } else {
622                String::from_utf8_lossy(src.raw.as_ref())
623            };
624            let code = s.replace('\t', " ");
625            let line_starts = compute_line_starts(&code);
626            CodeCacheEntry {
627                // Replace tab characters with a single space. This doesn't
628                // affect code spans, because the number of characters remain
629                // the same, but prevents error messages from being wrongly
630                // formatted when they are printed.
631                code,
632                line_starts,
633                origin: src.origin.clone(),
634            }
635        });
636
637        source_id
638    }
639
640    /// Returns the fragment from the current source code indicated by `span`.
641    pub fn get_snippet(&self, span: Span) -> String {
642        let source_id = self.get_current_source_id().unwrap();
643        let code_cache = self.code_cache.read();
644        let cache_entry = code_cache.get(&source_id).unwrap();
645        let src = cache_entry.code.as_str();
646
647        src[span.range()].to_string()
648    }
649
650    /// Creates a new error or warning report.
651    pub fn create_report(
652        &self,
653        level: Level,
654        code: &'static str,
655        title: String,
656        labels: Vec<(Level, CodeLoc, String)>,
657        footers: Vec<(Level, Option<String>)>,
658    ) -> Report {
659        // Make sure there's at least one label.
660        assert!(!labels.is_empty());
661
662        // Remove footers where text is None.
663        let footers = footers
664            .into_iter()
665            .filter_map(|(level, text)| text.map(|text| (level, text)))
666            .collect();
667
668        Report {
669            code_cache: self.code_cache.clone(),
670            with_colors: self.with_colors,
671            max_width: self.max_width,
672            level,
673            code,
674            title,
675            labels,
676            footers,
677            sections: Vec::new(),
678        }
679    }
680}
681
682fn level_as_text(level: &Level) -> &'static str {
683    match *level {
684        Level::ERROR => "error",
685        Level::WARNING => "warning",
686        Level::INFO => "info",
687        Level::NOTE => "note",
688        Level::HELP => "help",
689        _ => panic!("unsupported level {level:?}"),
690    }
691}
692
693fn compute_line_starts(text: &str) -> Vec<usize> {
694    let mut line_starts = vec![0];
695    for (i, c) in text.char_indices() {
696        if c == '\n' {
697            line_starts.push(i + 1);
698        }
699    }
700    line_starts
701}
702
703#[cfg(test)]
704mod tests {
705    use crate::compiler::report::{CodeCacheEntry, compute_line_starts};
706
707    fn helper(text: &str, offset: usize) -> Option<(usize, usize)> {
708        let line_starts = compute_line_starts(text);
709        let entry = CodeCacheEntry {
710            code: text.to_string(),
711            line_starts,
712            origin: None,
713        };
714        entry.byte_offset_to_line_col(offset)
715    }
716
717    #[test]
718    fn byte_offset_to_line_col_single_line() {
719        let text = "Hello, World!";
720        assert_eq!(helper(text, 0), Some((1, 1))); // Start of the string
721        assert_eq!(helper(text, 7), Some((1, 8))); // Byte offset of 'W'
722        assert_eq!(helper(text, 12), Some((1, 13))); // Byte offset of '!'
723    }
724
725    #[test]
726    fn byte_offset_to_line_col_multiline() {
727        let text = "Hello\nRust\nWorld!";
728        assert_eq!(helper(text, 0), Some((1, 1))); // First character
729        assert_eq!(helper(text, 5), Some((1, 6))); // End of first line (newline)
730        assert_eq!(helper(text, 6), Some((2, 1))); // Start of second line ('R')
731        assert_eq!(helper(text, 9), Some((2, 4))); // Byte offset of 't' in "Rust"
732        assert_eq!(helper(text, 11), Some((3, 1))); // Start of third line ('W')
733    }
734
735    #[test]
736    fn byte_offset_to_line_col_empty_string() {
737        let text = "";
738        assert_eq!(helper(text, 0), Some((1, 1)));
739    }
740
741    #[test]
742    fn byte_offset_to_line_col_out_of_bounds() {
743        let text = "Hello, World!";
744        assert_eq!(helper(text, text.len() + 1), None);
745    }
746
747    #[test]
748    fn byte_offset_to_line_col_end_of_string() {
749        let text = "Hello, World!";
750        assert_eq!(helper(text, text.len()), Some((1, 14))); // Last position after '!'
751    }
752
753    #[test]
754    fn byte_offset_to_line_col_multibyte_characters() {
755        let text = "Hello, 你好!";
756        assert_eq!(helper(text, 7), Some((1, 8))); // Position of '你'
757        assert_eq!(helper(text, 8), None); // Position in the middle of '你'
758        assert_eq!(helper(text, 10), Some((1, 9))); // Position of '好'
759        assert_eq!(helper(text, 13), Some((1, 10))); // Position of '!'
760    }
761}