pmat 3.30.1

PMAT - Zero-config AI context generation and code quality toolkit (CLI, MCP)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
// Symbol extraction and file collection logic

#[allow(clippy::too_many_arguments)]
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "path_exists")]
pub async fn handle_analyze_symbol_table(
    project_path: PathBuf,
    format: crate::cli::SymbolTableOutputFormat,
    filter: Option<crate::cli::SymbolTypeFilter>,
    query: Option<String>,
    include: &[String],
    exclude: &[String],
    show_unreferenced: bool,
    show_references: bool,
    output: Option<PathBuf>,
    _perf: bool,
    top_files: usize,
) -> Result<()> {
    // missing_path_fails: a nonexistent path must exit non-zero naming the path,
    // not walk nothing and report an empty (but plausible-looking) table.
    crate::cli::ensure_analysis_path_exists(&project_path)?;

    crate::status_eprintln!("🔍 Building symbol table for project...");

    // Build the symbol table
    let table = build_symbol_table(&project_path, include, exclude, top_files).await?;

    // Apply filters
    let filtered = apply_filters(table, filter, query, top_files)?;

    // Format output
    let content = format_output(
        filtered,
        format,
        show_unreferenced,
        show_references,
        top_files,
    )?;

    // Write output
    if let Some(output_path) = output {
        tokio::fs::write(&output_path, &content).await?;
        crate::status_eprintln!("✅ Symbol table written to: {}", output_path.display());
    } else {
        println!("{content}");
    }

    Ok(())
}

// Build symbol table from project files
async fn build_symbol_table(
    project_path: &Path,
    include: &[String],
    exclude: &[String],
    top_files: usize,
) -> Result<SymbolTable> {
    // Get all relevant files. `collect_files` returns them sorted, so every
    // downstream ordering (symbol order, reference order, tie-breaks) is stable.
    let files = collect_files(project_path, include, exclude).await?;

    // Read every file once: definitions come from it, and so do the use sites.
    let mut sources = Vec::with_capacity(files.len());
    for file in files {
        let content = tokio::fs::read_to_string(&file).await?;
        sources.push(FileSource {
            path: file.to_string_lossy().to_string(),
            content,
        });
    }

    let mut symbols = Vec::new();
    for source in &sources {
        symbols.extend(extract_symbols_simple(&source.content, &source.path)?);
    }

    // Defect #654 (round 2): before this, every symbol carried exactly one
    // reference — its own Definition — so `unreferenced_symbols` contained all
    // 16944 of 16944 symbols and `most_referenced` was a list of 1s. Use sites
    // are now resolved from the sources; `unresolved` holds names we could not
    // attribute, which must never be reported as unreferenced.
    let unresolved = resolve_references(&sources, &mut symbols);

    let unreferenced = find_unreferenced_symbols(&symbols, &unresolved);
    let (most_referenced, referenced_symbol_count) = find_most_referenced(&symbols, top_files);

    Ok(SymbolTable {
        total_symbols: symbols.len(),
        symbols,
        unreferenced_symbols: unreferenced,
        most_referenced,
        referenced_symbol_count,
    })
}

// Collect files based on include/exclude patterns
async fn collect_files(
    project_path: &Path,
    include: &[String],
    exclude: &[String],
) -> Result<Vec<PathBuf>> {
    let mut files = Vec::new();

    if project_path.is_file() {
        process_file(project_path.to_path_buf(), &mut files, include)?;
        return Ok(files);
    }

    collect_files_recursive(project_path, &mut files, include, exclude).await?;

    // `read_dir` yields entries in filesystem order, which is not stable across
    // machines (and not guaranteed stable on one). Sorting here is what makes
    // two runs over an unchanged tree byte-identical.
    files.sort();

    Ok(files)
}

// Recursively collect files
async fn collect_files_recursive(
    dir: &Path,
    files: &mut Vec<PathBuf>,
    include: &[String],
    exclude: &[String],
) -> Result<()> {
    let mut entries = tokio::fs::read_dir(dir).await?;

    while let Some(entry) = entries.next_entry().await? {
        process_directory_entry(entry, files, include, exclude).await?;
    }

    Ok(())
}

/// Process a single directory entry
async fn process_directory_entry(
    entry: tokio::fs::DirEntry,
    files: &mut Vec<PathBuf>,
    include: &[String],
    exclude: &[String],
) -> Result<()> {
    let path = entry.path();

    if should_skip_path(&path, exclude) {
        return Ok(());
    }

    if path.is_dir() {
        process_directory(&path, files, include, exclude).await
    } else {
        process_file(path, files, include)
    }
}

/// Check if path should be skipped
///
/// Defect #654: this used to take `Option<String>` built with `patterns.join(",")`,
/// so "no --exclude given" arrived as `Some("")` and `path.contains("")` is true for
/// every path — every file and directory was skipped and `total_symbols` was always 0,
/// even for the whole pmat source tree. An empty pattern list now excludes nothing.
fn should_skip_path(path: &Path, exclude: &[String]) -> bool {
    exclude.iter().any(|pattern| matches_pattern(path, pattern))
}

/// Process a directory
async fn process_directory(
    path: &Path,
    files: &mut Vec<PathBuf>,
    include: &[String],
    exclude: &[String],
) -> Result<()> {
    if should_process_directory(path) {
        Box::pin(collect_files_recursive(path, files, include, exclude)).await?;
    }
    Ok(())
}

/// Check if directory should be processed
fn should_process_directory(path: &Path) -> bool {
    let name = path.file_name().unwrap_or_default().to_string_lossy();
    !name.starts_with('.') && name != "node_modules" && name != "target"
}

/// Process a file
fn process_file(path: PathBuf, files: &mut Vec<PathBuf>, include: &[String]) -> Result<()> {
    if !is_source_file(&path) {
        return Ok(());
    }

    if should_include_file(&path, include) {
        files.push(path);
    }
    Ok(())
}

/// Check if file should be included (an empty pattern list includes everything)
fn should_include_file(path: &Path, include: &[String]) -> bool {
    include.is_empty() || include.iter().any(|pattern| matches_pattern(path, pattern))
}

/// Match a path against a user-supplied pattern.
///
/// Glob patterns (`*.rs`, `src/**/mod.rs`) are matched against both the full path and
/// the file name; plain patterns fall back to substring matching. Defect #654 also
/// reported `--include '*.rs'` yielding 0 symbols because the old code only ever did
/// `path.contains("*.rs")`, which no real path satisfies.
fn matches_pattern(path: &Path, pattern: &str) -> bool {
    if pattern.is_empty() {
        return false;
    }

    let path_str = path.to_string_lossy();

    if pattern.contains('*') || pattern.contains('?') || pattern.contains('[') {
        if let Ok(glob) = glob::Pattern::new(pattern) {
            let file_name = path.file_name().map(|n| n.to_string_lossy().to_string());
            return glob.matches(&path_str) || file_name.is_some_and(|name| glob.matches(&name));
        }
    }

    path_str.contains(pattern)
}

// Check if file is a source file
fn is_source_file(path: &Path) -> bool {
    matches!(
        path.extension().and_then(|s| s.to_str()),
        Some("rs" | "js" | "ts" | "py" | "java" | "cpp" | "c" | "h" | "hpp" | "go" | "rb")
    )
}

// Simple symbol extraction using regex
fn extract_symbols_simple(content: &str, file: &str) -> Result<Vec<Symbol>> {
    use regex::Regex;

    let mut symbols = Vec::new();

    // Function patterns for different languages.
    //
    // Issue #693: every pattern used to be anchored at `(?m)^` with no leading
    // whitespace allowed, and they are applied one line at a time below — so
    // `^` could only ever match column 0 and *any indented declaration was
    // invisible by construction*: `impl Widget { pub fn new }`, a trait's own
    // `fn draw`, anything inside an inline `mod inner { … }`. A 12-declaration
    // fixture reported 5. Each anchor is now `^\s*`; `detect_visibility` below
    // takes the text before the name, so a leading indent in that prefix is
    // harmless. `trait` and `type` had no pattern at all, and the `class`
    // pattern had no `export` prefix — which is why `class PlainClass` was
    // found while `export class ExportedClass` right above it was not.
    let patterns = vec![
        // `const fn` is a function declaration, not a constant. Without the
        // optional `const\s+` here, `const fn answer()` matched no function
        // pattern at all and `answer` was missing from the table entirely.
        (
            Regex::new(r"(?m)^\s*(?:pub\s+)?(?:const\s+)?(?:async\s+)?fn\s+(\w+)")?,
            SymbolKind::Function,
        ),
        (
            Regex::new(r"(?m)^\s*(?:export\s+)?class\s+(\w+)")?,
            SymbolKind::Class,
        ),
        (
            Regex::new(r"(?m)^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)")?,
            SymbolKind::Function,
        ),
        (Regex::new(r"(?m)^\s*def\s+(\w+)")?, SymbolKind::Function),
        // `--help` offers `--filter variables` ("Variables and constants") and
        // `--filter modules` ("Modules and namespaces"), but nothing ever
        // produced a `Variable` or a `Module`, so a fixture with `pub const
        // KONST`, `pub static STAT` and `pub mod inner` returned 0 for both —
        // two of the six advertised filter values could not match anything.
        //
        // The old constant pattern was `^const\s+(\w+)\s*=`, which a Rust
        // `pub const KONST: u32 = 1;` fails twice over (the `pub`, and the type
        // annotation before `=`). This one covers both it and JS `const x = …`.
        //
        // Requiring the `:` (Rust type annotation) or `=` (JS initialiser) that
        // must follow a real constant's name is what keeps `const fn answer()`
        // out: the bare `const\s+(\w+)` form captured the `fn` *keyword* as a
        // constant named "fn", which then out-ranked every real identifier in
        // `most_referenced` (54 266 "references" on pmat itself).
        (
            Regex::new(r"(?m)^\s*(?:pub\s+)?const\s+(\w+)\s*[:=]")?,
            SymbolKind::Constant,
        ),
        // Same keyword-capture trap as `const fn`: `static mut COUNTER` used to
        // yield a symbol literally named "mut".
        (
            Regex::new(r"(?m)^\s*(?:pub\s+)?static\s+(?:mut\s+)?(\w+)\s*[:=]")?,
            SymbolKind::Variable,
        ),
        (
            Regex::new(r"(?m)^\s*(?:pub\s+)?mod\s+(\w+)")?,
            SymbolKind::Module,
        ),
        (
            Regex::new(r"(?m)^\s*(?:pub\s+)?struct\s+(\w+)")?,
            SymbolKind::Type,
        ),
        (
            Regex::new(r"(?m)^\s*(?:pub\s+)?enum\s+(\w+)")?,
            SymbolKind::Enum,
        ),
        (
            Regex::new(r"(?m)^\s*(?:export\s+)?interface\s+(\w+)")?,
            SymbolKind::Interface,
        ),
        // #693: `trait` and `type` had no pattern at all, so `pub trait
        // Drawable` and `pub type WidgetAlias = Widget;` were absent from the
        // table entirely — as was every TypeScript `export type`.
        (
            Regex::new(r"(?m)^\s*(?:pub\s+|export\s+)?trait\s+(\w+)")?,
            SymbolKind::Interface,
        ),
        (
            Regex::new(r"(?m)^\s*(?:pub\s+|export\s+)?type\s+(\w+)")?,
            SymbolKind::Type,
        ),
    ];

    for (line_no, line) in content.lines().enumerate() {
        for (pattern, kind) in &patterns {
            if let Some(captures) = pattern.captures(line) {
                if let Some(name) = captures.get(1) {
                    symbols.push(Symbol {
                        name: name.as_str().to_string(),
                        kind: kind.clone(),
                        file: file.to_string(),
                        line: line_no + 1,
                        column: name.start(),
                        // Only the text BEFORE the declared name can be a
                        // modifier of it. Passing the whole line reported the
                        // private `struct PrivType { pub b: u32 }` as Public,
                        // because a public *field* put "pub " somewhere on the
                        // line. (The same struct spread over several lines was
                        // correctly Internal, which is the tell.)
                        visibility: detect_visibility(&line[..name.start()]),
                        references: vec![Reference {
                            file: file.to_string(),
                            line: line_no + 1,
                            column: name.start(),
                            kind: ReferenceKind::Definition,
                        }],
                    });
                }
            }
        }
    }

    Ok(symbols)
}

/// Detect visibility from the modifiers that precede the declared name.
///
/// `prefix` must be the part of the declaration line *before* the symbol's own
/// name — anything after it belongs to the body, not to the declaration.
fn detect_visibility(prefix: &str) -> Visibility {
    if prefix.contains("pub ") || prefix.contains("export ") {
        Visibility::Public
    } else if prefix.contains("private ") {
        Visibility::Private
    } else if prefix.contains("protected ") {
        Visibility::Protected
    } else {
        Visibility::Internal
    }
}

/// Total resolved use sites per symbol name (definitions excluded), so that a
/// name declared in several files is reported once rather than once per file.
fn usage_counts_by_name(symbols: &[Symbol]) -> HashMap<&str, usize> {
    let mut counts: HashMap<&str, usize> = HashMap::new();
    for symbol in symbols {
        *counts.entry(symbol.name.as_str()).or_insert(0) += usage_count(symbol);
    }
    counts
}

/// Names with zero resolved use sites.
///
/// Defect #654 (round 2): this used to be `references.len() <= 1`, and every
/// symbol had exactly one reference (its own `Definition`), so it returned every
/// symbol in the tree — 16944 of 16944, including `helper_one`, which a fixture
/// proved was called from both `main()` and `helper_two()`. It now counts real
/// use sites, and a name whose uses could not be attributed (see
/// `resolve_references`) is omitted rather than falsely declared unreferenced.
fn find_unreferenced_symbols(symbols: &[Symbol], unresolved: &HashSet<String>) -> Vec<String> {
    let counts = usage_counts_by_name(symbols);
    let mut names: Vec<String> = counts
        .into_iter()
        .filter(|(name, count)| *count == 0 && !unresolved.contains(*name))
        .map(|(name, _)| name.to_string())
        .collect();
    names.sort();
    names
}

/// Top symbol names by resolved use sites, highest first, name-ascending on a
/// tie so the list is identical across runs, plus **how many names there were in
/// total**. Names with no resolved use are omitted — a "most referenced" list of
/// zeros is not a measurement.
///
/// `limit` is `--top-files`; 0 means "all". The list used to be `truncate(10)`
/// with the flag discarded and no total reported, so a project with 11 000
/// referenced names and one with 11 produced the same-shaped 10-entry list.
fn find_most_referenced(symbols: &[Symbol], limit: usize) -> (Vec<(String, usize)>, usize) {
    let mut refs: Vec<(String, usize)> = usage_counts_by_name(symbols)
        .into_iter()
        .filter(|(_, count)| *count > 0)
        .map(|(name, count)| (name.to_string(), count))
        .collect();

    refs.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
    let total = refs.len();
    if limit > 0 {
        refs.truncate(limit);
    }
    (refs, total)
}

#[cfg_attr(coverage_nightly, coverage(off))]
#[cfg(test)]
mod keyword_capture_tests {
    use super::*;

    /// `const fn answer()` used to produce a `Constant` named "fn" (the keyword
    /// captured by the constant regex) and no `answer` at all, because the
    /// function regex had no `const` alternative. Repo-wide that bogus "fn"
    /// topped `most_referenced` with 54 266 textual "references".
    #[test]
    fn const_fn_is_a_function_named_after_the_fn_not_the_keyword() {
        let content = "const fn answer() -> i32 { 42 }\npub fn other() -> i32 { answer() }\n";
        let symbols = extract_symbols_simple(content, "lib.rs").unwrap();

        let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect();
        assert!(
            !names.contains(&"fn"),
            "the `fn` keyword must never become a symbol: {names:?}"
        );
        assert!(
            symbols
                .iter()
                .any(|s| s.name == "answer" && matches!(s.kind, SymbolKind::Function)),
            "`const fn answer` must be a Function named answer: {names:?}"
        );
    }

    /// Same keyword-capture trap on the `static` pattern.
    #[test]
    fn static_mut_is_named_after_the_variable_not_the_mut_keyword() {
        let content = "pub static mut COUNTER: u32 = 0;\n";
        let symbols = extract_symbols_simple(content, "lib.rs").unwrap();

        let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect();
        assert!(!names.contains(&"mut"), "captured the keyword: {names:?}");
        assert!(names.contains(&"COUNTER"), "lost the variable: {names:?}");
    }

    /// The constant/variable patterns must still find the ordinary forms.
    #[test]
    fn plain_constants_and_statics_are_still_extracted() {
        let content = "pub const KONST: u32 = 1;\npub static STAT: u32 = 2;\nconst js = 3;\n";
        let symbols = extract_symbols_simple(content, "lib.rs").unwrap();
        let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect();
        assert!(names.contains(&"KONST"), "{names:?}");
        assert!(names.contains(&"STAT"), "{names:?}");
        assert!(names.contains(&"js"), "{names:?}");
    }

    /// Issue #693: every pattern was `(?m)^`-anchored and applied per line, so
    /// a declaration that is not at column 0 could never match. This exact
    /// fixture reported 5 of its declarations; the ones below were all silently
    /// absent from a table that still printed `total_symbols` as if it were a
    /// count of what is there.
    #[test]
    fn indented_declarations_are_not_invisible() {
        let content = concat!(
            "pub struct Widget {\n",
            "    pub id: u32,\n",
            "}\n",
            "\n",
            "pub trait Drawable {\n",
            "    fn draw(&self);\n",
            "}\n",
            "\n",
            "impl Widget {\n",
            "    pub fn new() -> Self { Widget { id: 0 } }\n",
            "    fn helper(&self) -> u32 { self.id }\n",
            "}\n",
            "\n",
            "pub type WidgetAlias = Widget;\n",
            "\n",
            "mod inner {\n",
            "    pub fn nested_fn() {}\n",
            "}\n",
            "\n",
            "pub fn top_level() {}\n",
        );
        let symbols = extract_symbols_simple(content, "a.rs").expect("extract");
        let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect();

        for expected in [
            "Widget",
            "Drawable",
            "draw",
            "new",
            "helper",
            "WidgetAlias",
            "inner",
            "nested_fn",
            "top_level",
        ] {
            assert!(
                names.contains(&expected),
                "`{expected}` is declared in the fixture but missing from the table: {names:?}"
            );
        }

        // An indented `pub fn` is public; the indent must not be mistaken for a
        // missing `pub` (or vice versa).
        let new_fn = symbols
            .iter()
            .find(|s| s.name == "new")
            .expect("`new` must be extracted");
        assert!(
            matches!(new_fn.visibility, Visibility::Public),
            "indented `pub fn new` reported as {:?}",
            new_fn.visibility
        );
        let helper = symbols
            .iter()
            .find(|s| s.name == "helper")
            .expect("`helper` must be extracted");
        assert!(
            matches!(helper.visibility, Visibility::Internal),
            "indented private `fn helper` reported as {:?}",
            helper.visibility
        );
    }

    /// Issue #693's tell: `class PlainClass` was found while
    /// `export class ExportedClass` on the line above it was not, because the
    /// class pattern had no `(?:export\s+)?` prefix — so a TypeScript file's
    /// *exported* types were exactly the ones missing.
    #[test]
    fn exported_typescript_declarations_are_extracted() {
        let content = concat!(
            "export class ExportedClass {\n",
            "    method() { return 1; }\n",
            "}\n",
            "class PlainClass {}\n",
            "export function exportedFn() {}\n",
            "export interface Shape {}\n",
            "export type Alias = Shape;\n",
        );
        let symbols = extract_symbols_simple(content, "b.ts").expect("extract");
        let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect();

        for expected in [
            "ExportedClass",
            "PlainClass",
            "exportedFn",
            "Shape",
            "Alias",
        ] {
            assert!(names.contains(&expected), "missing `{expected}`: {names:?}");
        }
    }
}