1use std::collections::{BTreeMap, BTreeSet};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use tokei::{CodeStats, Config, LanguageType, Languages};
8use tokmd_types::{ChildIncludeMode, FileKind, FileRow};
9
10use crate::module_key::module_key_from_normalized;
11use crate::normalize_path;
12use crate::sorting::sort_file_rows;
13
14const CHARS_PER_TOKEN: usize = 4;
16
17#[derive(Default, Clone, Copy)]
18struct Agg {
19 code: usize,
20 comments: usize,
21 blanks: usize,
22 bytes: usize,
23 tokens: usize,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
27struct Key<'a> {
28 path: String,
29 lang: &'a str,
30 kind: FileKind,
31}
32
33pub struct InMemoryRowInput<'a> {
35 pub logical_path: &'a Path,
36 pub bytes: &'a [u8],
37}
38
39impl<'a> InMemoryRowInput<'a> {
40 #[must_use]
41 pub fn new(logical_path: &'a Path, bytes: &'a [u8]) -> Self {
42 Self {
43 logical_path,
44 bytes,
45 }
46 }
47}
48
49fn get_file_metrics(path: &Path) -> (usize, usize) {
50 let bytes = fs::metadata(path).map(|m| m.len() as usize).unwrap_or(0);
54 metrics_from_byte_len(bytes)
55}
56
57fn metrics_from_bytes(bytes: &[u8]) -> (usize, usize) {
58 metrics_from_byte_len(bytes.len())
59}
60
61fn metrics_from_byte_len(bytes: usize) -> (usize, usize) {
62 let tokens = bytes / CHARS_PER_TOKEN;
63 (bytes, tokens)
64}
65
66fn synthetic_detection_path(logical_path: &Path) -> PathBuf {
67 let mut path = PathBuf::from("__tokmd_in_memory_detection__");
68 path.push(logical_path.file_name().unwrap_or(logical_path.as_os_str()));
69 path
70}
71
72fn language_from_in_memory_shebang(bytes: &[u8]) -> Option<LanguageType> {
73 const READ_LIMIT: usize = 128;
74
75 let first_line = bytes[..bytes.len().min(READ_LIMIT)]
76 .split(|b| *b == b'\n')
77 .next()?;
78 let first_line = std::str::from_utf8(first_line).ok()?;
79
80 let direct = LanguageType::list()
81 .iter()
82 .map(|(lang, _)| *lang)
83 .find(|lang| lang.shebangs().contains(&first_line));
84 if direct.is_some() {
85 return direct;
86 }
87
88 let mut words = first_line.split_whitespace();
89 if words.next() == Some("#!/usr/bin/env") {
90 let interpreter = env_interpreter_token(words)?;
91 return language_from_env_interpreter(interpreter);
92 }
93
94 None
95}
96
97fn env_interpreter_token<'a>(words: impl Iterator<Item = &'a str>) -> Option<&'a str> {
98 let mut skip_next = false;
99
100 for word in words {
101 if skip_next {
102 skip_next = false;
103 continue;
104 }
105
106 if word.is_empty() {
107 continue;
108 }
109
110 if looks_like_env_assignment(word) {
111 continue;
112 }
113
114 match word {
115 "-S" | "--split-string" | "-i" | "--ignore-environment" => continue,
116 "-u" | "--unset" | "-C" | "--chdir" | "-P" | "--default-path" | "-a" | "--argv0"
117 | "--default-signal" | "--ignore-signal" | "--block-signal" => {
118 skip_next = true;
119 continue;
120 }
121 _ if word.starts_with("--unset=")
122 || word.starts_with("--chdir=")
123 || word.starts_with("--default-path=")
124 || word.starts_with("--argv0=")
125 || word.starts_with("--default-signal=")
126 || word.starts_with("--ignore-signal=")
127 || word.starts_with("--block-signal=") =>
128 {
129 continue;
130 }
131 _ if word.starts_with('-') => continue,
132 _ => return Some(word),
133 }
134 }
135
136 None
137}
138
139fn looks_like_env_assignment(word: &str) -> bool {
140 let Some((name, _)) = word.split_once('=') else {
141 return false;
142 };
143
144 if name.is_empty() {
145 return false;
146 }
147
148 let mut chars = name.chars();
149 let Some(first) = chars.next() else {
150 return false;
151 };
152
153 if !(first == '_' || first.is_ascii_alphabetic()) {
154 return false;
155 }
156
157 chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
158}
159
160fn language_from_env_interpreter(interpreter: &str) -> Option<LanguageType> {
161 let token = interpreter
162 .rsplit('/')
163 .next()
164 .unwrap_or(interpreter)
165 .trim_start_matches('-');
168
169 if token.starts_with("python") {
170 return LanguageType::from_file_extension("py");
171 }
172
173 match token {
174 "bash" | "sh" | "zsh" | "ksh" | "fish" => LanguageType::from_name("Bash"),
175 "node" | "nodejs" => LanguageType::from_name("JavaScript"),
176 "ruby" => LanguageType::from_name("Ruby"),
177 "perl" | "perl5" => LanguageType::from_name("Perl"),
178 "php" => LanguageType::from_name("PHP"),
179 "pwsh" | "powershell" => LanguageType::from_name("PowerShell"),
180 _ => None,
181 }
182}
183
184fn detect_in_memory_language(
185 logical_path: &Path,
186 bytes: &[u8],
187 config: &Config,
188) -> Option<LanguageType> {
189 let detection_path = synthetic_detection_path(logical_path);
190 LanguageType::from_path(&detection_path, config)
191 .or_else(|| language_from_in_memory_shebang(bytes))
192}
193
194#[inline]
195fn insert_row<'a>(
196 map: &mut BTreeMap<Key<'a>, (String, Agg)>,
197 key: Key<'a>,
198 module: String,
199 stats: &CodeStats,
200 bytes: usize,
201 tokens: usize,
202) {
203 let entry = map.entry(key).or_insert_with(|| (module, Agg::default()));
204 entry.1.code += stats.code;
205 entry.1.comments += stats.comments;
206 entry.1.blanks += stats.blanks;
207 entry.1.bytes += bytes;
208 entry.1.tokens += tokens;
209}
210
211fn rows_from_map<'a>(map: BTreeMap<Key<'a>, (String, Agg)>) -> Vec<FileRow> {
212 map.into_iter()
213 .map(|(key, (module, agg))| {
214 let lines = agg.code + agg.comments + agg.blanks;
215 FileRow {
216 path: key.path,
217 module,
218 lang: key.lang.to_string(),
219 kind: key.kind,
220 code: agg.code,
221 comments: agg.comments,
222 blanks: agg.blanks,
223 lines,
224 bytes: agg.bytes,
225 tokens: agg.tokens,
226 }
227 })
228 .collect()
229}
230
231pub fn collect_in_memory_file_rows(
236 inputs: &[InMemoryRowInput<'_>],
237 module_roots: &[String],
238 module_depth: usize,
239 children: ChildIncludeMode,
240 config: &Config,
241) -> Vec<FileRow> {
242 let mut map = BTreeMap::new();
243
244 for input in inputs {
245 let Some(lang_type) = detect_in_memory_language(input.logical_path, input.bytes, config)
246 else {
247 continue;
248 };
249
250 let path = normalize_path(input.logical_path, None);
251 let module = module_key_from_normalized(&path, module_roots, module_depth);
252 let stats = lang_type.parse_from_slice(input.bytes, config);
253 let summary = stats.summarise();
254 let (bytes, tokens) = metrics_from_bytes(input.bytes);
255
256 if children == ChildIncludeMode::Separate {
257 for (child_type, child_stats) in &stats.blobs {
258 let child_summary = child_stats.summarise();
259 insert_row(
260 &mut map,
261 Key {
262 path: path.clone(),
263 lang: child_type.name(),
264 kind: FileKind::Child,
265 },
266 module.clone(),
267 &child_summary,
268 0,
269 0,
270 );
271 }
272 }
273
274 insert_row(
275 &mut map,
276 Key {
277 path,
278 lang: lang_type.name(),
279 kind: FileKind::Parent,
280 },
281 module,
282 &summary,
283 bytes,
284 tokens,
285 );
286 }
287
288 let mut rows = rows_from_map(map);
289 sort_file_rows(&mut rows);
290 rows
291}
292
293pub fn collect_file_rows(
298 languages: &Languages,
299 module_roots: &[String],
300 module_depth: usize,
301 children: ChildIncludeMode,
302 strip_prefix: Option<&Path>,
303) -> Vec<FileRow> {
304 let mut map = BTreeMap::new();
305
306 for (lang_type, lang) in languages.iter() {
308 for report in &lang.reports {
309 let path = normalize_path(&report.name, strip_prefix);
310 let module = module_key_from_normalized(&path, module_roots, module_depth);
311 let st = report.stats.summarise();
312 let (bytes, tokens) = get_file_metrics(&report.name);
313 insert_row(
314 &mut map,
315 Key {
316 path,
317 lang: lang_type.name(),
318 kind: FileKind::Parent,
319 },
320 module,
321 &st,
322 bytes,
323 tokens,
324 );
325 }
326 }
327
328 if children == ChildIncludeMode::Separate {
329 for (_lang_type, lang) in languages.iter() {
330 for (child_type, reports) in &lang.children {
331 for report in reports {
332 let path = normalize_path(&report.name, strip_prefix);
333 let module = module_key_from_normalized(&path, module_roots, module_depth);
334 let st = report.stats.summarise();
335 insert_row(
336 &mut map,
337 Key {
338 path,
339 lang: child_type.name(),
340 kind: FileKind::Child,
341 },
342 module,
343 &st,
344 0,
345 0,
346 );
347 }
348 }
349 }
350 }
351
352 let mut rows = rows_from_map(map);
353 sort_file_rows(&mut rows);
354 rows
355}
356
357pub fn unique_parent_file_count(languages: &Languages) -> usize {
358 let rows = collect_file_rows(languages, &[], 1, ChildIncludeMode::ParentsOnly, None);
359 unique_parent_file_count_from_rows(&rows)
360}
361
362pub fn unique_parent_file_count_from_rows(file_rows: &[FileRow]) -> usize {
363 file_rows
364 .iter()
365 .filter(|row| row.kind == FileKind::Parent)
366 .map(|row| row.path.as_str())
367 .collect::<BTreeSet<_>>()
368 .len()
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374
375 #[test]
376 fn looks_like_env_assignment_identifies_valid_names() {
377 assert!(looks_like_env_assignment("FOO=bar"));
378 assert!(looks_like_env_assignment("_FOO=bar"));
379 assert!(looks_like_env_assignment("A_B_C=123"));
380
381 assert!(!looks_like_env_assignment("="));
382 assert!(!looks_like_env_assignment("=bar"));
383 assert!(!looks_like_env_assignment("1FOO=bar"));
384 assert!(!looks_like_env_assignment("FOO-BAR=baz"));
385 }
386
387 #[test]
388 fn byte_metrics_use_floor_token_estimate() {
389 assert_eq!(metrics_from_byte_len(0), (0, 0));
390 assert_eq!(metrics_from_byte_len(12), (12, 3));
391 assert_eq!(metrics_from_byte_len(15), (15, 3));
392 assert_eq!(metrics_from_bytes(b"hello world!"), (12, 3));
393 }
394
395 #[test]
396 fn env_interpreter_token_skips_env_arguments() {
397 assert_eq!(
398 env_interpreter_token(vec!["python"].into_iter()),
399 Some("python")
400 );
401
402 assert_eq!(
403 env_interpreter_token(vec!["FOO=bar", "python"].into_iter()),
404 Some("python")
405 );
406
407 assert_eq!(
408 env_interpreter_token(vec!["-S", "-i", "python"].into_iter()),
409 Some("python")
410 );
411 assert_eq!(
412 env_interpreter_token(vec!["--split-string", "python"].into_iter()),
413 Some("python")
414 );
415 assert_eq!(
416 env_interpreter_token(vec!["--ignore-environment", "python"].into_iter()),
417 Some("python")
418 );
419
420 assert_eq!(
421 env_interpreter_token(vec!["-u", "FOO", "-C", "/tmp", "python"].into_iter()),
422 Some("python")
423 );
424 assert_eq!(
425 env_interpreter_token(vec!["--unset", "FOO", "python"].into_iter()),
426 Some("python")
427 );
428
429 assert_eq!(
430 env_interpreter_token(vec!["--unset=FOO", "python"].into_iter()),
431 Some("python")
432 );
433 assert_eq!(
434 env_interpreter_token(vec!["--chdir=/tmp", "python"].into_iter()),
435 Some("python")
436 );
437 assert_eq!(
438 env_interpreter_token(vec!["--default-path=/bin", "python"].into_iter()),
439 Some("python")
440 );
441 assert_eq!(
442 env_interpreter_token(vec!["--argv0=sh", "python"].into_iter()),
443 Some("python")
444 );
445 assert_eq!(
446 env_interpreter_token(vec!["--default-signal=SIGINT", "python"].into_iter()),
447 Some("python")
448 );
449 assert_eq!(
450 env_interpreter_token(vec!["--ignore-signal=SIGINT", "python"].into_iter()),
451 Some("python")
452 );
453 assert_eq!(
454 env_interpreter_token(vec!["--block-signal=SIGINT", "python"].into_iter()),
455 Some("python")
456 );
457
458 assert_eq!(
459 env_interpreter_token(vec!["--unknown-flag", "python"].into_iter()),
460 Some("python")
461 );
462
463 assert_eq!(
464 env_interpreter_token(vec!["", "python"].into_iter()),
465 Some("python")
466 );
467
468 assert_eq!(env_interpreter_token(vec!["FOO=bar"].into_iter()), None);
469 }
470
471 #[test]
472 fn language_from_env_interpreter_recognizes_supported_aliases() {
473 assert_eq!(
474 language_from_env_interpreter("/usr/local/bin/python3"),
475 LanguageType::from_file_extension("py")
476 );
477 assert_eq!(
478 language_from_env_interpreter("nodejs"),
479 LanguageType::from_name("JavaScript")
480 );
481 assert_eq!(
482 language_from_env_interpreter("-bash"),
483 LanguageType::from_name("Bash")
484 );
485 assert_eq!(language_from_env_interpreter("unknown-tool"), None);
486 }
487
488 #[test]
489 fn collect_in_memory_rows_detects_env_shebang_without_extension() {
490 let config = Config::default();
491 let bytes = b"#!/usr/bin/env -S python3 -O\nprint('hello')\n";
492 let input = InMemoryRowInput::new(Path::new("tools/greet"), bytes);
493
494 let rows =
495 collect_in_memory_file_rows(&[input], &[], 1, ChildIncludeMode::ParentsOnly, &config);
496
497 assert_eq!(rows.len(), 1);
498 let row = &rows[0];
499 assert_eq!(row.path, "tools/greet");
500 assert_eq!(row.module, "tools");
501 assert_eq!(row.lang, "Python");
502 assert_eq!(row.kind, FileKind::Parent);
503 assert_eq!(row.bytes, bytes.len());
504 assert_eq!(row.tokens, bytes.len() / CHARS_PER_TOKEN);
505 assert!(row.code > 0);
506 }
507
508 #[test]
509 fn collect_in_memory_rows_aggregates_duplicate_path_language_kind() {
510 let config = Config::default();
511 let first = b"print('one')\n";
512 let second = b"print('two')\n";
513 let inputs = [
514 InMemoryRowInput::new(Path::new("src/main.py"), first),
515 InMemoryRowInput::new(Path::new("src/main.py"), second),
516 ];
517
518 let rows =
519 collect_in_memory_file_rows(&inputs, &[], 1, ChildIncludeMode::ParentsOnly, &config);
520
521 assert_eq!(rows.len(), 1);
522 let row = &rows[0];
523 assert_eq!(row.path, "src/main.py");
524 assert_eq!(row.module, "src");
525 assert_eq!(row.lang, "Python");
526 assert_eq!(row.kind, FileKind::Parent);
527 assert_eq!(row.bytes, first.len() + second.len());
528 assert_eq!(
529 row.tokens,
530 (first.len() / CHARS_PER_TOKEN) + (second.len() / CHARS_PER_TOKEN)
531 );
532 assert_eq!(row.lines, row.code + row.comments + row.blanks);
533 }
534}