Skip to main content

tree_sitter_cli/
test_highlight.rs

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