howmany 3.0.0

A blazingly fast, intelligent code analysis tool with parallel processing, caching, and beautiful visualizations
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
//! Throughput benchmarks for the analysis pipeline.
//!
//! Each group answers one question, so a regression points at a stage rather
//! than at "the tool got slower":
//!
//! * `scan` -- lines classified per second, with no filesystem involved.
//! * `discover` -- files found per second, counting excluded.
//! * `pipeline` -- the whole run, which is what a user waits for.
//! * `threads` -- how the pipeline scales, so a scaling regression is visible.
//! * `cache` -- what a warm cache is worth on an unchanged tree.
//!
//! Corpora are generated in a temporary directory from a fixed seed, so results
//! are comparable between runs and between machines with the same core count.
//! `Throughput` is set on every group, which makes Criterion report MiB/s
//! instead of only wall time -- and, for discovery, files/s alongside it, since
//! the file count is the unit that walk cost actually tracks.

use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use howmany::core::counter::{comment_patterns, scanner, CodeCounter};
use howmany::core::engine::{AnalysisOptions, DetectionMode, Engine, Parallelism};
use howmany::core::filters::FileFilter;
use std::hint::black_box;
use std::path::{Path, PathBuf};
use tempfile::TempDir;

/// A generated corpus, kept alive for the duration of a benchmark.
struct Corpus {
    _dir: TempDir,
    root: PathBuf,
    files: usize,
    bytes: u64,
}

/// Deterministic pseudo-random source. A fixed algorithm keeps the corpus
/// identical across platforms, which `rand` would not guarantee.
struct Rng(u64);

impl Rng {
    fn next(&mut self) -> u64 {
        // xorshift64*: cheap, and stable across releases.
        self.0 ^= self.0 >> 12;
        self.0 ^= self.0 << 25;
        self.0 ^= self.0 >> 27;
        self.0.wrapping_mul(0x2545_F491_4F6C_DD1D)
    }

    fn below(&mut self, bound: usize) -> usize {
        (self.next() % bound as u64) as usize
    }
}

const LANGUAGES: &[(&str, &str, Option<&str>)] = &[
    ("rs", "//", Some("///")),
    ("py", "#", None),
    ("js", "//", Some("/**")),
    ("go", "//", None),
    ("java", "//", Some("/**")),
    ("c", "//", Some("/**")),
];

/// Build one file's worth of source with a realistic mix of line kinds.
fn file_body(rng: &mut Rng, lines: usize, single: &str, doc: Option<&str>) -> String {
    let mut out = String::with_capacity(lines * 40);
    for index in 0..lines {
        match rng.below(100) {
            0..=11 => {}
            12..=27 => out.push_str(&format!("    {single} explanatory note {index}")),
            28..=35 => match doc {
                Some("/**") => out.push_str(&format!("/** documented {index} */")),
                Some(marker) => out.push_str(&format!("{marker} documented {index}")),
                None => out.push_str(&format!("    value_{index} = compute({index});")),
            },
            _ => out.push_str(&format!("    value_{index} = compute({index});")),
        }
        out.push('\n');
    }
    out
}

/// Generate a corpus with `files` source files spread over a realistic tree,
/// plus `files / 2` files inside directories that must be pruned.
///
/// The noise is the point of the discovery benchmark: a walk that prunes
/// `node_modules` never reads it, and the difference has to be measurable.
fn corpus(files: usize) -> Corpus {
    let dir = TempDir::new().expect("temporary directory");
    let root = dir.path().to_path_buf();
    let mut rng = Rng(0x1337_C0DE);
    let mut bytes = 0u64;

    for index in 0..files {
        let (ext, single, doc) = LANGUAGES[index % LANGUAGES.len()];
        // 8 files per directory, nested three deep: broad at the leaves.
        let parent = root
            .join(format!("crate{}", index / 512))
            .join(format!("mod{}", (index / 64) % 8))
            .join(format!("sub{}", (index / 8) % 8));
        std::fs::create_dir_all(&parent).expect("create source directory");

        let lines = 5 + rng.below(395);
        let body = file_body(&mut rng, lines, single, doc);
        bytes += body.len() as u64;
        std::fs::write(parent.join(format!("mod_{index}.{ext}")), &body).expect("write source");
    }

    for index in 0..files / 2 {
        let noise = root
            .join(["node_modules", "target", "dist", "__pycache__", ".git"][index % 5])
            .join(format!("pkg{}", index % 32));
        std::fs::create_dir_all(&noise).expect("create noise directory");
        let lines = 20 + rng.below(100);
        let body = file_body(&mut rng, lines, "//", None);
        std::fs::write(noise.join(format!("vendored_{index}.js")), &body).expect("write noise");
    }

    Corpus {
        _dir: dir,
        root,
        files,
        bytes,
    }
}

/// Options that measure the tool itself: no external detector, no cache.
fn bench_options(parallelism: Parallelism) -> AnalysisOptions {
    AnalysisOptions {
        detection: DetectionMode::Disabled,
        parallelism,
        use_cache: false,
        collect_individual_files: false,
        compute_complexity: false,
        ..AnalysisOptions::default()
    }
}

/// Line classification with no filesystem in the way.
fn bench_scan(c: &mut Criterion) {
    let mut group = c.benchmark_group("scan");

    for (ext, single, doc) in LANGUAGES {
        let mut rng = Rng(0xABCD_EF01);
        let body = file_body(&mut rng, 20_000, single, *doc);
        let pattern = comment_patterns::lookup_or_empty(ext);

        group.throughput(Throughput::Bytes(body.len() as u64));
        group.bench_with_input(BenchmarkId::new("lines", ext), &body, |b, body| {
            b.iter(|| {
                let tally = scanner::classify(&mut body.as_bytes(), pattern).expect("classify");
                black_box(tally)
            })
        });
    }

    group.finish();
}

/// Counting one file end to end, including the open and the stat.
fn bench_count_file(c: &mut Criterion) {
    let dir = TempDir::new().expect("temporary directory");
    let mut rng = Rng(0x5EED);
    let body = file_body(&mut rng, 2_000, "//", Some("///"));
    let path = dir.path().join("bench.rs");
    std::fs::write(&path, &body).expect("write source");

    let counter = CodeCounter::new();
    let mut group = c.benchmark_group("count_file");
    group.throughput(Throughput::Bytes(body.len() as u64));
    group.bench_function("2000_lines", |b| {
        b.iter(|| black_box(counter.count_file(&path).expect("count")))
    });
    group.finish();
}

/// Traversal only: how fast candidates are produced, and what pruning saves.
fn bench_discover(c: &mut Criterion) {
    let corpus = corpus(4_000);
    let engine = Engine::new();

    let mut group = c.benchmark_group("discover");
    group.sample_size(30);
    group.throughput(Throughput::ElementsAndBytes {
        elements: corpus.files as u64,
        bytes: corpus.bytes,
    });

    for (label, parallelism) in [
        ("sequential", Parallelism::Fixed(1)),
        ("parallel", Parallelism::Auto),
    ] {
        let options = bench_options(parallelism);
        group.bench_function(label, |b| {
            b.iter(|| {
                black_box(
                    engine
                        .discover_files(&corpus.root, &options)
                        .expect("discover"),
                )
            })
        });
    }

    // Without pruning, the walk reads every vendored file only to reject it.
    group.bench_function("unpruned", |b| {
        let filter = FileFilter::new().prune_build_dirs(false);
        b.iter(|| {
            let count = filter
                .try_walk_directory(&corpus.root)
                .expect("walk")
                .count();
            black_box(count)
        })
    });
    group.bench_function("pruned", |b| {
        let filter = FileFilter::new().prune_build_dirs(true);
        b.iter(|| {
            let count = filter
                .try_walk_directory(&corpus.root)
                .expect("walk")
                .count();
            black_box(count)
        })
    });

    group.finish();
}

/// The whole pipeline, at the sizes a user actually runs it on.
fn bench_pipeline(c: &mut Criterion) {
    let engine = Engine::new();
    let mut group = c.benchmark_group("pipeline");
    group.sample_size(20);

    for files in [500usize, 4_000] {
        let corpus = corpus(files);
        group.throughput(Throughput::Bytes(corpus.bytes));

        for (label, parallelism) in [
            ("sequential", Parallelism::Fixed(1)),
            ("parallel", Parallelism::Auto),
        ] {
            let options = bench_options(parallelism);
            group.bench_with_input(BenchmarkId::new(label, files), &corpus.root, |b, root| {
                b.iter(|| black_box(engine.analyze(root, &options).expect("analyze")))
            });
        }
    }

    group.finish();
}

/// Scaling. A flat or rising curve here is the signal that parallel counting
/// has stopped paying for itself.
fn bench_threads(c: &mut Criterion) {
    let corpus = corpus(4_000);
    let engine = Engine::new();

    let mut group = c.benchmark_group("threads");
    group.sample_size(20);
    group.throughput(Throughput::Bytes(corpus.bytes));

    let available = std::thread::available_parallelism()
        .map(|n| n.get())
        .unwrap_or(1);
    for threads in [1, 2, 4, 8, 16]
        .into_iter()
        .filter(|t| *t <= available.max(1))
    {
        let options = bench_options(Parallelism::Fixed(threads));
        group.bench_with_input(BenchmarkId::from_parameter(threads), &threads, |b, _| {
            b.iter(|| black_box(engine.analyze(&corpus.root, &options).expect("analyze")))
        });
    }

    group.finish();
}

/// What a warm cache buys on an unchanged tree.
fn bench_cache(c: &mut Criterion) {
    let corpus = corpus(2_000);
    let cache_dir = TempDir::new().expect("temporary directory");
    std::env::set_var("HOWMANY_CACHE_DIR", cache_dir.path());

    let engine = Engine::new();
    let cold = AnalysisOptions {
        use_cache: false,
        ..bench_options(Parallelism::Auto)
    };
    let warm = AnalysisOptions {
        use_cache: true,
        ..bench_options(Parallelism::Auto)
    };

    // Populate before measuring, so the warm case is genuinely warm.
    engine.analyze(&corpus.root, &warm).expect("populate cache");

    let mut group = c.benchmark_group("cache");
    group.sample_size(20);
    group.throughput(Throughput::Bytes(corpus.bytes));
    group.bench_function("cold", |b| {
        b.iter(|| black_box(engine.analyze(&corpus.root, &cold).expect("analyze")))
    });
    group.bench_function("warm", |b| {
        b.iter(|| black_box(engine.analyze(&corpus.root, &warm).expect("analyze")))
    });
    group.finish();

    std::env::remove_var("HOWMANY_CACHE_DIR");
}

/// What language detection costs when it cannot change the answer.
///
/// Detection is a separate process that re-walks the whole tree, and blocking on
/// it used to account for roughly three quarters of a run. It is now started but
/// cancelled once discovery shows that every file was classified without it, so
/// `auto` and `disabled` should be indistinguishable on a corpus of well-known
/// extensions. A gap reappearing here means the cancellation stopped working.
fn bench_detection(c: &mut Criterion) {
    let corpus = corpus(4_000);
    let engine = Engine::new();

    let mut group = c.benchmark_group("detection");
    group.sample_size(20);
    group.throughput(Throughput::Bytes(corpus.bytes));

    for (label, detection) in [
        ("disabled", DetectionMode::Disabled),
        ("auto", DetectionMode::Auto),
    ] {
        let options = AnalysisOptions {
            detection,
            ..bench_options(Parallelism::Auto)
        };
        group.bench_function(label, |b| {
            b.iter(|| black_box(engine.analyze(&corpus.root, &options).expect("analyze")))
        });
    }

    group.finish();
}

/// What the complexity and quality metrics cost on top of counting.
///
/// Reports that print those metrics now compute them, so this is the price of
/// the fix and the number to watch if a report path gets slow.
fn bench_complexity(c: &mut Criterion) {
    let corpus = corpus(2_000);
    let engine = Engine::new();

    let mut group = c.benchmark_group("complexity");
    group.sample_size(20);
    group.throughput(Throughput::Bytes(corpus.bytes));

    for (label, compute) in [("counts_only", false), ("with_complexity", true)] {
        let options = AnalysisOptions {
            compute_complexity: compute,
            ..bench_options(Parallelism::Auto)
        };
        group.bench_function(label, |b| {
            b.iter(|| black_box(engine.analyze(&corpus.root, &options).expect("analyze")))
        });
    }

    group.finish();
}

/// A corpus of files the counter has to handle without a fast path: no trailing
/// newline, CRLF, invalid UTF-8, one enormous line.
fn bench_pathological(c: &mut Criterion) {
    let dir = TempDir::new().expect("temporary directory");
    let root: &Path = dir.path();

    std::fs::write(
        root.join("huge_line.rs"),
        format!("// {}\n", "x".repeat(4_000_000)),
    )
    .expect("write");
    std::fs::write(
        root.join("crlf.rs"),
        "fn main() {}\r\n// c\r\n\r\n".repeat(20_000),
    )
    .expect("write");
    std::fs::write(
        root.join("not_utf8.rs"),
        [b"fn a() {}\n\xff\xfe\x80\n".as_slice(); 20_000].concat(),
    )
    .expect("write");

    let engine = Engine::new();
    let options = bench_options(Parallelism::Auto);

    let mut group = c.benchmark_group("pathological");
    group.sample_size(20);
    group.bench_function("mixed", |b| {
        b.iter(|| black_box(engine.analyze(root, &options).expect("analyze")))
    });
    group.finish();
}

criterion_group!(
    benches,
    bench_scan,
    bench_count_file,
    bench_discover,
    bench_pipeline,
    bench_threads,
    bench_cache,
    bench_detection,
    bench_complexity,
    bench_pathological
);
criterion_main!(benches);