tree_sitter_cli/
test_highlight.rs

1use std::{fs, path::Path};
2
3use anstyle::AnsiColor;
4use anyhow::{anyhow, Result};
5use tree_sitter::Point;
6use tree_sitter_highlight::{Highlight, HighlightConfiguration, HighlightEvent, Highlighter};
7use tree_sitter_loader::{Config, Loader};
8
9use super::{
10    query_testing::{parse_position_comments, to_utf8_point, Assertion, Utf8Point},
11    test::paint,
12    util,
13};
14
15#[derive(Debug)]
16pub struct Failure {
17    row: usize,
18    column: usize,
19    expected_highlight: String,
20    actual_highlights: Vec<String>,
21}
22
23impl std::error::Error for Failure {}
24
25impl std::fmt::Display for Failure {
26    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
27        write!(
28            f,
29            "Failure - row: {}, column: {}, expected highlight '{}', actual highlights: ",
30            self.row, self.column, self.expected_highlight
31        )?;
32        if self.actual_highlights.is_empty() {
33            write!(f, "none.")?;
34        } else {
35            for (i, actual_highlight) in self.actual_highlights.iter().enumerate() {
36                if i > 0 {
37                    write!(f, ", ")?;
38                }
39                write!(f, "'{actual_highlight}'")?;
40            }
41        }
42        Ok(())
43    }
44}
45
46pub fn test_highlights(
47    loader: &Loader,
48    loader_config: &Config,
49    highlighter: &mut Highlighter,
50    directory: &Path,
51    use_color: bool,
52) -> Result<()> {
53    println!("syntax highlighting:");
54    test_highlights_indented(loader, loader_config, highlighter, directory, use_color, 2)
55}
56
57fn test_highlights_indented(
58    loader: &Loader,
59    loader_config: &Config,
60    highlighter: &mut Highlighter,
61    directory: &Path,
62    use_color: bool,
63    indent_level: usize,
64) -> Result<()> {
65    let mut failed = false;
66
67    for highlight_test_file in fs::read_dir(directory)? {
68        let highlight_test_file = highlight_test_file?;
69        let test_file_path = highlight_test_file.path();
70        let test_file_name = highlight_test_file.file_name();
71        print!(
72            "{indent:indent_level$}",
73            indent = "",
74            indent_level = indent_level * 2
75        );
76        if test_file_path.is_dir() && test_file_path.read_dir()?.next().is_some() {
77            println!("{}:", test_file_name.to_string_lossy());
78            if test_highlights_indented(
79                loader,
80                loader_config,
81                highlighter,
82                &test_file_path,
83                use_color,
84                indent_level + 1,
85            )
86            .is_err()
87            {
88                failed = true;
89            }
90        } else {
91            let (language, language_config) = loader
92                .language_configuration_for_file_name(&test_file_path)?
93                .ok_or_else(|| {
94                    anyhow!(
95                        "{}",
96                        util::lang_not_found_for_path(test_file_path.as_path(), loader_config)
97                    )
98                })?;
99            let highlight_config = language_config
100                .highlight_config(language, None)?
101                .ok_or_else(|| anyhow!("No highlighting config found for {test_file_path:?}"))?;
102            match test_highlight(
103                loader,
104                highlighter,
105                highlight_config,
106                fs::read(&test_file_path)?.as_slice(),
107            ) {
108                Ok(assertion_count) => {
109                    println!(
110                        "✓ {} ({assertion_count} assertions)",
111                        paint(
112                            use_color.then_some(AnsiColor::Green),
113                            test_file_name.to_string_lossy().as_ref()
114                        ),
115                    );
116                }
117                Err(e) => {
118                    println!(
119                        "✗ {}",
120                        paint(
121                            use_color.then_some(AnsiColor::Red),
122                            test_file_name.to_string_lossy().as_ref()
123                        )
124                    );
125                    println!(
126                        "{indent:indent_level$}  {e}",
127                        indent = "",
128                        indent_level = indent_level * 2
129                    );
130                    failed = true;
131                }
132            }
133        }
134    }
135
136    if failed {
137        Err(anyhow!(""))
138    } else {
139        Ok(())
140    }
141}
142pub fn iterate_assertions(
143    assertions: &[Assertion],
144    highlights: &[(Utf8Point, Utf8Point, Highlight)],
145    highlight_names: &[String],
146) -> Result<usize> {
147    // Iterate through all of the highlighting assertions, checking each one against the
148    // actual highlights.
149    let mut i = 0;
150    let mut actual_highlights = Vec::new();
151    for Assertion {
152        position,
153        length,
154        negative,
155        expected_capture_name: expected_highlight,
156    } in assertions
157    {
158        let mut passed = false;
159        let mut end_column = position.column + length - 1;
160        actual_highlights.clear();
161
162        // The assertions are ordered by position, so skip past all of the highlights that
163        // end at or before this assertion's position.
164        'highlight_loop: while let Some(highlight) = highlights.get(i) {
165            if highlight.1 <= *position {
166                i += 1;
167                continue;
168            }
169
170            // Iterate through all of the highlights that start at or before this assertion's
171            // position, looking for one that matches the assertion.
172            let mut j = i;
173            while let (false, Some(highlight)) = (passed, highlights.get(j)) {
174                end_column = position.column + length - 1;
175                if highlight.0.column > end_column {
176                    break 'highlight_loop;
177                }
178
179                // If the highlight matches the assertion, or if the highlight doesn't
180                // match the assertion but it's negative, this test passes. Otherwise,
181                // add this highlight to the list of actual highlights that span the
182                // assertion's position, in order to generate an error message in the event
183                // of a failure.
184                let highlight_name = &highlight_names[(highlight.2).0];
185                if (*highlight_name == *expected_highlight) == *negative {
186                    actual_highlights.push(highlight_name);
187                } else {
188                    passed = true;
189                    break 'highlight_loop;
190                }
191
192                j += 1;
193            }
194        }
195
196        if !passed {
197            return Err(Failure {
198                row: position.row,
199                column: end_column,
200                expected_highlight: expected_highlight.clone(),
201                actual_highlights: actual_highlights.into_iter().cloned().collect(),
202            }
203            .into());
204        }
205    }
206
207    Ok(assertions.len())
208}
209
210pub fn test_highlight(
211    loader: &Loader,
212    highlighter: &mut Highlighter,
213    highlight_config: &HighlightConfiguration,
214    source: &[u8],
215) -> Result<usize> {
216    // Highlight the file, and parse out all of the highlighting assertions.
217    let highlight_names = loader.highlight_names();
218    let highlights = get_highlight_positions(loader, highlighter, highlight_config, source)?;
219    let assertions =
220        parse_position_comments(highlighter.parser(), &highlight_config.language, source)?;
221
222    iterate_assertions(&assertions, &highlights, &highlight_names)
223}
224
225pub fn get_highlight_positions(
226    loader: &Loader,
227    highlighter: &mut Highlighter,
228    highlight_config: &HighlightConfiguration,
229    source: &[u8],
230) -> Result<Vec<(Utf8Point, Utf8Point, Highlight)>> {
231    let mut row = 0;
232    let mut column = 0;
233    let mut byte_offset = 0;
234    let mut was_newline = false;
235    let mut result = Vec::new();
236    let mut highlight_stack = Vec::new();
237    let source = String::from_utf8_lossy(source);
238    let mut char_indices = source.char_indices();
239    for event in highlighter.highlight(highlight_config, source.as_bytes(), None, |string| {
240        loader.highlight_config_for_injection_string(string)
241    })? {
242        match event? {
243            HighlightEvent::HighlightStart(h) => highlight_stack.push(h),
244            HighlightEvent::HighlightEnd => {
245                highlight_stack.pop();
246            }
247            HighlightEvent::Source { start, end } => {
248                let mut start_position = Point::new(row, column);
249                while byte_offset < end {
250                    if byte_offset <= start {
251                        start_position = Point::new(row, column);
252                    }
253                    if let Some((i, c)) = char_indices.next() {
254                        if was_newline {
255                            row += 1;
256                            column = 0;
257                        } else {
258                            column += i - byte_offset;
259                        }
260                        was_newline = c == '\n';
261                        byte_offset = i;
262                    } else {
263                        break;
264                    }
265                }
266                if let Some(highlight) = highlight_stack.last() {
267                    let utf8_start_position = to_utf8_point(start_position, source.as_bytes());
268                    let utf8_end_position =
269                        to_utf8_point(Point::new(row, column), source.as_bytes());
270                    result.push((utf8_start_position, utf8_end_position, *highlight));
271                }
272            }
273        }
274    }
275    Ok(result)
276}