Skip to main content

mezura_core/
explain.rs

1use std::path::Path;
2
3use crate::{EngineConfig, Language, LineClass, LineClasses, ScanSkip, Span};
4use crate::domain::CommentPair;
5use crate::engine::file_parser::{CarriedRecord, NestedLanguageLookup, explain_parsed_file, find_scan_skip};
6use crate::languages::Languages;
7
8/// One file read line by line, as [`explain_file`] answers it.
9#[derive(Debug)]
10pub struct FileExplanation {
11    /// The language whose rules read the file.
12    pub language: String,
13    /// The evidence that identified a contested file, the literal and its line, or None where the
14    /// extension alone answered.
15    pub identified_by: Option<(String, usize)>,
16    /// Why a directory scan under the same configuration would leave this file out of the counts,
17    /// or None where it would count it. A file given by name is counted either way.
18    pub left_out_of_a_scan: Option<ScanSkip>,
19    /// The file as it was read, so a caller can show each line beside its answer.
20    pub contents: String,
21    /// One entry per line of the file, in order.
22    pub lines: Vec<ExplainedLine>,
23    /// The whole file's counts, which are what a run would have added for it.
24    pub classes: LineClasses,
25}
26
27/// What one line of the file came to.
28#[derive(Debug)]
29pub struct ExplainedLine {
30    /// Which of the nine it was sorted into.
31    pub class: LineClass,
32    /// `Some` where a nested language's rules read the line, `None` where the file's own did.
33    pub read_as: Option<String>,
34    /// What earlier lines had left open when this one began.
35    pub carried: Carried,
36    /// The line cut into its stretches of code, string and comment, in order and touching each
37    /// other. Whitespace at either end of the line sits outside them, and a blank line has none.
38    pub spans: Vec<Span>,
39}
40
41/// What was open when a line began.
42#[derive(Debug, PartialEq)]
43pub enum Carried {
44    /// Nothing: the line starts outside every string and comment.
45    Nothing,
46    /// A block comment.
47    Comment {
48        /// The opening symbol as the file spells it. A long-bracket pair is spelled with its
49        /// level, `--[==[`.
50        opener: String,
51        /// How deep the nesting goes, for a pair that nests. A long-bracket pair always says 1,
52        /// since its level is in the opener instead.
53        depth: u32,
54        /// The line the comment opened on, counted from 1.
55        since_line: usize,
56        /// Whether it is gone by this line's end: closed on it, or replaced by a new one of the
57        /// same symbol that this line itself opened. A `*/ code /*` line carries the old comment
58        /// and ends it, and the next line's answer names the new opener.
59        ends_on_this_line: bool
60    },
61    /// A string running over several lines.
62    Str {
63        /// The opening symbol as the file spells it.
64        opener: String,
65        /// The line the string opened on, counted from 1.
66        since_line: usize,
67        /// Whether it is gone by this line's end, the same way a comment's is.
68        ends_on_this_line: bool
69    },
70    /// The line was joined to the one before it by a line continuation inside a comment.
71    CommentContinuation {
72        /// The line that comment opened on.
73        since_line: usize
74    },
75}
76
77/// Why a file could not be explained.
78#[derive(Debug, PartialEq)]
79#[non_exhaustive]
80pub enum ExplainError {
81    /// The languages were resolved against a configuration that selects a different set from the
82    /// one handed in. Refused for the reason [`crate::run`] refuses the same pair: the answer would
83    /// look perfectly normal and be for a different set of languages than the settings describe.
84    LanguagesFromAnotherConfig,
85    /// No language in play claims this file, so there are no symbols to read it with.
86    UnclaimedFile,
87    /// The file could not be read, with what went wrong.
88    UnreadableFile(String),
89}
90
91impl std::fmt::Display for ExplainError {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        match self {
94            Self::LanguagesFromAnotherConfig => write!(f, "The languages were resolved against a configuration that selects a different set of them than the one this explanation was given, so the file would be read with the wrong symbols. Resolve them against the same configuration."),
95            Self::UnclaimedFile => write!(f, "No language in play claims this file, so there is nothing to read it with."),
96            Self::UnreadableFile(x) => write!(f, "The file could not be read: {x}")
97        }
98    }
99}
100
101impl std::error::Error for ExplainError {}
102
103/// Reads one file and answers for every line of it: which class it landed in, what earlier lines
104/// had left open, and which language's rules read it.
105///
106/// The answers come out of the same walk that counts, so they cannot disagree with the totals.
107///
108/// The languages must have been resolved against this same configuration, the way [`crate::run`]
109/// demands.
110pub fn explain_file(path: &Path, config: &EngineConfig, languages: Languages)
111    -> Result<FileExplanation, ExplainError>
112{
113    if !languages.describe_the_same_selection_as(config) {
114        return Err(ExplainError::LanguagesFromAnotherConfig);
115    }
116    let (by_name, lookups, nested_definitions) = languages.into_parts();
117    // Under the rules of the module the file was named in, so that '--explain ios=./a.m' answers
118    // with the same language the report would have counted it as. Matched against the target that
119    // is this very file, so a config naming several falls back to the rules of the whole run rather
120    // than to whichever target happens to be first.
121    let module = config.targets.iter().find(|target| Path::new(&target.path) == path)
122            .and_then(|target| target.module.as_deref());
123    let lookup = lookups.get_of_module_named(module);
124    let Some(mut lang_name) = lookup.of_path_or_shebang(path) else {
125        return Err(ExplainError::UnclaimedFile);
126    };
127    let contents = std::fs::read_to_string(path)
128            .map_err(|error| ExplainError::UnreadableFile(error.to_string()))?;
129    let mut identified_by = None;
130    let extension_rules = lookup.find_extension_rules(path);
131    if let Some(contenders) = extension_rules.as_ref().and_then(|rules| rules.contenders.as_deref())
132        && let Some((name, literal, line)) = crate::engine::file_parser::find_identified_language(
133                &contents, contenders, &by_name, &lookup.by_shebang) {
134        identified_by = Some((literal, line));
135        lang_name = name;
136    }
137    let left_out_of_a_scan = find_scan_skip(&contents, extension_rules.as_deref(), config);
138    let nested_lookup = NestedLanguageLookup {
139        languages: &by_name,
140        extension_to_name: &nested_definitions.extension_to_name,
141        set_aside: &nested_definitions.set_aside,
142    };
143    let (contents, report, log) = explain_parsed_file(contents, &lang_name, &nested_lookup, config);
144
145    let language = lang_name.to_string();
146    let (records, names) = log.into_parts();
147    let lines = records.into_iter().map(|record| {
148        let read_by = names[record.language as usize].as_str();
149        ExplainedLine {
150            class: record.class,
151            read_as: (read_by != language).then(|| read_by.to_owned()),
152            carried: spell_out_carried(record.carried, nested_lookup.find_by_name(read_by)),
153            spans: record.spans,
154        }
155    }).collect::<Vec<_>>();
156
157    let whole = report.into_whole();
158    debug_assert_eq!(whole.lines, lines.len(),
159            "a file of {} lines got {} per-line records", whole.lines, lines.len());
160    Ok(FileExplanation { language, identified_by, left_out_of_a_scan, contents, lines,
161            classes: whole.classes })
162}
163
164// The record holds symbol numbers; what a reader gets is the symbol as the file spells it. The
165// language is the one that read the line, and a record's language always resolves, so the fallback
166// arm is never the answer.
167fn spell_out_carried(carried: CarriedRecord, language: Option<&Language>) -> Carried {
168    match carried {
169        CarriedRecord::Nothing => Carried::Nothing,
170        CarriedRecord::Continuation { since_line } => Carried::CommentContinuation { since_line },
171        CarriedRecord::Str { symbol, since_line, ends } => Carried::Str {
172            opener: language.map(|x| x.get_string_pair_of(symbol).0.to_owned()).unwrap_or_default(),
173            since_line, ends_on_this_line: ends
174        },
175        CarriedRecord::Comment { symbol, depth, since_line, ends } => {
176            let (opener, depth) = language.map(|x| spell_comment_opener(x, symbol, depth))
177                    .unwrap_or((String::new(), depth));
178            Carried::Comment { opener, depth, since_line, ends_on_this_line: ends }
179        }
180    }
181}
182
183fn spell_comment_opener(language: &Language, symbol: u8, depth: u32) -> (String, u32) {
184    match language.get_comment_pair_of(symbol) {
185        CommentPair::Plain { start, .. } | CommentPair::Nesting { start, .. } => (start.to_owned(), depth),
186        // The walk carries the level in the depth slot, and the filler is the '=' the scan plan
187        // counts, so the opener comes back exactly as written
188        CommentPair::Leveled(pair) => (format!("{}{}{}", pair.start_prefix,
189                "=".repeat(depth as usize), pair.start_suffix as char), 1)
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use std::fs;
196    use std::path::PathBuf;
197
198    use crate::{CountingModel, EngineConfig};
199    use crate::test_paths;
200
201    use super::*;
202
203    fn resolved_languages(config: &EngineConfig) -> Languages {
204        let languages = crate::language_file::parse_languages_in_dir(test_paths::LANGUAGES_DIR).unwrap().0;
205        Languages::resolve(config, languages, &Default::default()).0
206    }
207
208    fn explain_in_own_dir(test_name: &str, file_name: &str, contents: &str) -> FileExplanation {
209        let root = std::env::temp_dir().join(test_name);
210        let _ = fs::remove_dir_all(&root);
211        fs::create_dir_all(&root).unwrap();
212        let path = root.join(file_name);
213        fs::write(&path, contents).unwrap();
214
215        let config = EngineConfig::default();
216        let explained = explain_file(&path, &config, resolved_languages(&config)).unwrap();
217        fs::remove_dir_all(&root).unwrap();
218        explained
219    }
220
221    #[test]
222    fn a_carried_comment_and_string_name_their_opener_and_its_line() {
223        let explained = explain_in_own_dir("mezura-explain-carried", "a.rs",
224                "fn main() {\n/* first\n\nlast */\nlet s = \"one\n два\";\n}\n");
225
226        assert_eq!("Rust", explained.language);
227        assert_eq!(7, explained.lines.len());
228        assert_eq!(explained.lines.len(), explained.classes.calculate_lines());
229
230        let carried = explained.lines.iter().map(|line| &line.carried).collect::<Vec<_>>();
231        assert_eq!(&Carried::Nothing, carried[0]);
232        assert_eq!(&Carried::Nothing, carried[1]);
233        assert_eq!(&Carried::Comment { opener: "/*".to_owned(), depth: 1, since_line: 2,
234                ends_on_this_line: false }, carried[2]);
235        assert_eq!(&Carried::Comment { opener: "/*".to_owned(), depth: 1, since_line: 2,
236                ends_on_this_line: true }, carried[3]);
237        assert_eq!(&Carried::Nothing, carried[4]);
238        assert_eq!(&Carried::Str { opener: "\"".to_owned(), since_line: 5,
239                ends_on_this_line: true }, carried[5]);
240        assert_eq!(&Carried::Nothing, carried[6]);
241
242        assert_eq!(LineClass::BlankInComment, explained.lines[2].class);
243        assert_eq!(LineClass::StringContent, explained.lines[5].class);
244        assert!(explained.lines.iter().all(|line| line.read_as.is_none()));
245    }
246
247    #[test]
248    fn a_leveled_opener_is_spelled_with_its_level() {
249        let explained = explain_in_own_dir("mezura-explain-leveled", "a.lua",
250                "x = 1\n--[==[ words\nmore words\n]==]\n");
251
252        assert_eq!("Lua", explained.language);
253        assert_eq!(&Carried::Comment { opener: "--[==[".to_owned(), depth: 1, since_line: 2,
254                ends_on_this_line: false }, &explained.lines[2].carried);
255    }
256
257    #[test]
258    fn every_line_is_cut_into_its_string_and_comment_stretches() {
259        let explained = explain_in_own_dir("mezura-explain-spans", "a.rs",
260                "fn main() {\n/* first\n\nlast */\nlet s = \"one\n два\";\n}\n");
261
262        let spans = |at: usize| explained.lines[at].spans.iter()
263                .map(|span| (span.from, span.to, span.kind)).collect::<Vec<_>>();
264        assert_eq!(vec![(0, 11, crate::SpanKind::Code)], spans(0));
265        assert_eq!(vec![(0, 8, crate::SpanKind::Comment)], spans(1));
266        assert!(spans(2).is_empty());
267        assert_eq!(vec![(0, 7, crate::SpanKind::Comment)], spans(3));
268        assert_eq!(vec![(0, 8, crate::SpanKind::Code), (8, 12, crate::SpanKind::String)], spans(4));
269        // the leading space sits outside every span, and the offsets are bytes of the raw line,
270        // so the two-byte characters of 'два' count as two
271        assert_eq!(vec![(1, 8, crate::SpanKind::String), (8, 9, crate::SpanKind::Code)], spans(5));
272        assert_eq!(vec![(0, 1, crate::SpanKind::Code)], spans(6));
273    }
274
275    #[test]
276    fn a_line_that_ends_a_comment_and_opens_the_same_pair_moves_the_opening_line() {
277        let explained = explain_in_own_dir("mezura-explain-reopen", "a.rs",
278                "/*\nwords\n*/ pub enum A {} /*\nmore words\n*/ /*\nlast words\n*/\n");
279
280        let carried = explained.lines.iter().map(|line| &line.carried).collect::<Vec<_>>();
281        assert_eq!(&Carried::Comment { opener: "/*".to_owned(), depth: 1, since_line: 1,
282                ends_on_this_line: true }, carried[2]);
283        assert_eq!(&Carried::Comment { opener: "/*".to_owned(), depth: 1, since_line: 3,
284                ends_on_this_line: false }, carried[3]);
285        // and the bare '*/ /*' shape, with nothing between the closer and the opener, moves it too
286        assert_eq!(&Carried::Comment { opener: "/*".to_owned(), depth: 1, since_line: 3,
287                ends_on_this_line: true }, carried[4]);
288        assert_eq!(&Carried::Comment { opener: "/*".to_owned(), depth: 1, since_line: 5,
289                ends_on_this_line: false }, carried[5]);
290        assert_eq!(&Carried::Comment { opener: "/*".to_owned(), depth: 1, since_line: 5,
291                ends_on_this_line: true }, carried[6]);
292    }
293
294    #[test]
295    fn every_line_of_a_container_names_the_language_that_read_it() {
296        let explained = explain_in_own_dir("mezura-explain-container", "a.html",
297                "<p>hello</p>\n<style>\n/* a css comment */\nh1 { color: red; }\n</style>\n");
298
299        let read_as = explained.lines.iter()
300                .map(|line| line.read_as.as_deref()).collect::<Vec<_>>();
301        assert_eq!(vec![None, None, Some("CSS"), Some("CSS"), None], read_as);
302        assert_eq!(LineClass::WordsInComment, explained.lines[2].class);
303        assert_eq!(5, explained.classes.calculate_lines());
304    }
305
306    #[test]
307    fn the_per_line_buckets_add_up_to_the_folded_columns() {
308        let explained = explain_in_own_dir("mezura-explain-buckets", "a.rs",
309                "fn main() {\n// words\n\nlet x = 1; // words beside code\n}\n");
310
311        for model in [CountingModel::Content, CountingModel::Region] {
312            for bucket in [crate::Bucket::Code, crate::Bucket::Comments, crate::Bucket::Third] {
313                let per_line = explained.lines.iter()
314                        .filter(|line| model.fold(line.class) == bucket).count();
315                let folded = match bucket {
316                    crate::Bucket::Code => model.calculate_code_lines(&explained.classes),
317                    crate::Bucket::Comments => model.calculate_comment_lines(&explained.classes),
318                    crate::Bucket::Third => explained.classes.calculate_lines()
319                            - model.calculate_code_lines(&explained.classes)
320                            - model.calculate_comment_lines(&explained.classes)
321                };
322                assert_eq!(folded, per_line, "{model:?} {bucket:?}");
323            }
324        }
325    }
326
327    #[test]
328    fn a_file_no_language_claims_and_a_missing_file_are_refused_with_their_own_answers() {
329        let root = std::env::temp_dir().join("mezura-explain-refusals");
330        let _ = fs::remove_dir_all(&root);
331        fs::create_dir_all(&root).unwrap();
332        let unclaimed = root.join("a.unclaimed-extension");
333        fs::write(&unclaimed, "text\n").unwrap();
334
335        let config = EngineConfig::default();
336        assert_eq!(Err(ExplainError::UnclaimedFile),
337                explain_file(&unclaimed, &config, resolved_languages(&config))
338                        .map(|_| ()));
339        assert!(matches!(
340                explain_file(&root.join("missing.rs"), &config, resolved_languages(&config)).map(|_| ()),
341                Err(ExplainError::UnreadableFile(_))));
342        fs::remove_dir_all(&root).unwrap();
343    }
344
345    #[test]
346    fn languages_resolved_against_other_settings_are_refused() {
347        let narrowed = EngineConfig {
348            languages_of_interest: vec!["Rust".to_owned()].into(),
349            ..EngineConfig::default()
350        };
351        let languages = resolved_languages(&narrowed);
352        assert_eq!(Err(ExplainError::LanguagesFromAnotherConfig),
353                explain_file(&PathBuf::from("a.rs"), &EngineConfig::default(), languages).map(|_| ()));
354    }
355
356    // Keywords cannot move a class, so hiding them changes nothing here; asserted because the
357    // explain pass forces them off for speed whatever the configuration says
358    #[test]
359    fn the_answer_is_the_same_with_keywords_on_and_off() {
360        let root = std::env::temp_dir().join("mezura-explain-keywords");
361        let _ = fs::remove_dir_all(&root);
362        fs::create_dir_all(&root).unwrap();
363        let path = root.join("a.rs");
364        fs::write(&path, "struct A;\n// a struct\nfn f() {}\n").unwrap();
365
366        let with = EngineConfig { count_keywords: true, ..EngineConfig::default() };
367        let without = EngineConfig { count_keywords: false, ..EngineConfig::default() };
368        let explained_with = explain_file(&path, &with, resolved_languages(&with)).unwrap();
369        let explained_without = explain_file(&path, &without, resolved_languages(&without)).unwrap();
370        fs::remove_dir_all(&root).unwrap();
371
372        assert_eq!(explained_with.classes, explained_without.classes);
373        assert_eq!(explained_with.lines.len(), explained_without.lines.len());
374    }
375}