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#[derive(Debug)]
10pub struct FileExplanation {
11 pub language: String,
13 pub identified_by: Option<(String, usize)>,
16 pub left_out_of_a_scan: Option<ScanSkip>,
19 pub contents: String,
21 pub lines: Vec<ExplainedLine>,
23 pub classes: LineClasses,
25}
26
27#[derive(Debug)]
29pub struct ExplainedLine {
30 pub class: LineClass,
32 pub read_as: Option<String>,
34 pub carried: Carried,
36 pub spans: Vec<Span>,
39}
40
41#[derive(Debug, PartialEq)]
43pub enum Carried {
44 Nothing,
46 Comment {
48 opener: String,
51 depth: u32,
54 since_line: usize,
56 ends_on_this_line: bool
60 },
61 Str {
63 opener: String,
65 since_line: usize,
67 ends_on_this_line: bool
69 },
70 CommentContinuation {
72 since_line: usize
74 },
75}
76
77#[derive(Debug, PartialEq)]
79#[non_exhaustive]
80pub enum ExplainError {
81 LanguagesFromAnotherConfig,
85 UnclaimedFile,
87 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
103pub 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 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
164fn 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 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 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 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 #[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}