Skip to main content

tokmd_model/
rows.rs

1//! File-row collection for model receipts.
2
3use 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
14/// Simple heuristic: 1 token ~= 4 chars (bytes).
15const 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
33/// A logical in-memory file used to synthesize `FileRow`s without the host filesystem.
34pub 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    // Best-effort size calculation.
51    // If the file was deleted or is inaccessible during the scan post-processing,
52    // we return 0 bytes/tokens rather than crashing.
53    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        // Some shells and malformed env invocations can surface "-python3"-style
166        // interpreter tokens; strip the leading dash defensively before matching.
167        .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
231/// Collect `FileRow`s directly from ordered in-memory inputs.
232///
233/// This path avoids host filesystem metadata and keeps logical paths intact,
234/// which makes it suitable for browser/WASM callers.
235pub 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
293/// Collect per-file contributions, optionally including embedded language reports.
294///
295/// This returns one row per (path, lang, kind), aggregated if tokei produced multiple
296/// reports for the same tuple.
297pub 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    // Parent reports
307    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}