htl_core/cache.rs
1//! An on-disk cache of what checking one module reported (issues #3, #19).
2//!
3//! Building a checker costs about 13.5 ms and type-checking a few thousand lines of Teal
4//! costs about a second (`crates/htl-core/benches/check.rs`). A run that changed nothing
5//! should pay neither, and a run that changed one module should pay for that module and
6//! what depends on it rather than for the project.
7//!
8//! # One entry per module
9//!
10//! #3 shipped this as one entry per invocation, because at the time it looked as though
11//! skipping individual modules would change what the rest of them saw. Two things settled
12//! that: #21 made a file's result independent of its position in the walk, and measurement
13//! showed the shared module store to be an optimisation rather than a precondition — a
14//! dependency being checked first does not change what its requirer reports. So an entry
15//! is now per module, and a run is the sum of them.
16//!
17//! # What makes an entry
18//!
19//! The **key** is the module as this invocation names it — the path spelling the checker
20//! will put into the diagnostics, plus the lint selection and the working directory.
21//! `--strict` and `--format` are deliberately absent: they change how a run is summarized
22//! and what it exits with, not what any module reports, so runs that differ only in those
23//! share their modules' entries.
24//!
25//! The **inputs** are the module and everything reading it required, by content hash. The
26//! **probes** are the directories a `require` could resolve in, by the set of module names
27//! in each — a new `.tl` appearing earlier on the search path changes what a name resolves
28//! to while every recorded hash still matches, and nothing else would catch it. This is the
29//! hole ccache documents in its direct mode.
30//!
31//! # No mtimes anywhere
32//!
33//! Content hashes only. Timestamp-based invalidation is where this class of tool
34//! historically breaks — second-granular filesystems on macOS, mtimes zeroed by Docker
35//! layers, clock skew, fresh CI checkouts invalidating everything — and hashing a module
36//! costs microseconds against the milliseconds it saves.
37//!
38//! # Failure is a miss
39//!
40//! Every error path here returns "no entry" rather than propagating. A corrupt file, an
41//! unreadable directory, a store on a read-only filesystem: the module gets checked, as it
42//! would have been anyway. The one invariant worth stating is mypy's: an entry is written
43//! whole or not at all, via a temporary file and a rename, so a reader never sees half of
44//! one. Set `HTL_CACHE_DEBUG=1` to print why a lookup missed.
45//!
46//! # Why this lives in htl-core
47//!
48//! The store began in the CLI, which was the only reader. The linker is a reader too
49//! (#100): `htl build` and `include_bundle!` walk the require closure through
50//! [`crate::link`], and a module whose `gen` entry still holds — the same entry `htl test`
51//! writes and replays — has no reason to be generated again. `htl-cli` depends on `htl`,
52//! which depends on `htl-macros`, so a store the macros can open has to sit below both.
53//! The CLI keeps its flags, its `cache status` command and its report types; what moved is
54//! the store and the JSON shapes it writes, and the conversions between those shapes and
55//! [`crate::CheckInfo`], which every reader was carrying a copy of.
56//!
57//! Only `htl check` sweeps ([`Cache::sweep`]). It is the one reader that sees the whole
58//! project, so its keep-list is the whole project's; a build or a macro expansion sees one
59//! closure, and sweeping from that view would evict every other entry the project has.
60
61use anyhow::Result;
62use serde::{Deserialize, Serialize};
63use std::cell::RefCell;
64use std::collections::HashMap;
65use std::path::{Path, PathBuf};
66
67use crate::{CheckInfo, DependencyError, Fix, RequireSite};
68
69/// Bumped by hand when anything below changes shape. An entry stamped with a different
70/// value is a miss rather than an error: a fresh checkout and an upgrade both take that
71/// path in normal operation, which is why rustc treats its own header mismatch the same
72/// way.
73///
74/// 6: the stamp carries the checker's identity, and a `module` entry carries nothing
75/// else about the binary (#100), so the proc macros — whose `current_exe` is rustc, which
76/// does not change when htl does — still miss on a checker that no longer exists, and
77/// the CLI and the macros read each other's module entries.
78const FORMAT: u32 = 6;
79
80/// Where the store lives under the project root. Generated, and `htl init` puts it in
81/// `.gitignore`.
82const DIR: &str = ".htl/cache";
83
84/// How many entries a project's store may hold before a run starts dropping the ones it did
85/// not use.
86///
87/// Scaled to the project, because the natural size is one entry per module per way of
88/// invoking the check, and "a few ways" is what people do. The floor keeps a small project
89/// from evicting itself on its second invocation.
90fn default_bound(files: usize) -> usize {
91 (files * 4).max(256)
92}
93
94/// What produced an entry. A cache is only as good as its ability to notice it was
95/// written by something else.
96///
97/// The binary's length and modification time stand in for "which checker is this": during
98/// development the vendored Teal compiler, the prelude and the Rust side all change many
99/// times within one released version number, and a stamp that only carried the version
100/// would happily replay results from a checker that no longer exists. Rebuilding always
101/// moves both. This is the same reasoning behind sccache hashing the compiler binary into
102/// its key.
103///
104/// A [`MODULE`] entry — a required module's generated Lua — is stamped without the
105/// binary. Those entries are written by `htl test`, `htl build` and the proc macros, and
106/// inside a proc macro the binary is `rustc`: stamping with it would have the CLI and the
107/// macros overwrite each other's entries for the same module forever. What decides what a
108/// module generates is the Lua the checker is made of (the vendored `tl`, the prelude, the
109/// lints), so those entries carry [`crate::checker_identity`] — a hash of exactly that —
110/// beside the format and the htl version, and nothing that differs between two binaries
111/// built from the same sources.
112#[derive(Serialize, Deserialize, PartialEq, Eq, Debug)]
113struct Stamp {
114 format: u32,
115 htl: String,
116 checker: String,
117 exe_len: u64,
118 exe_mtime_ns: u128,
119}
120
121impl Stamp {
122 /// What this build stamps an entry of `kind` with: the binary too, except for
123 /// [`MODULE`] entries (see the type doc).
124 fn for_kind(kind: &str) -> Option<Self> {
125 if kind == MODULE {
126 Some(Self::portable())
127 } else {
128 Self::current()
129 }
130 }
131
132 fn current() -> Option<Self> {
133 let exe = std::env::current_exe().ok()?;
134 let m = std::fs::metadata(&exe).ok()?;
135 let mtime = m
136 .modified()
137 .ok()?
138 .duration_since(std::time::UNIX_EPOCH)
139 .ok()?
140 .as_nanos();
141 Some(Self {
142 exe_len: m.len(),
143 exe_mtime_ns: mtime,
144 ..Self::portable()
145 })
146 }
147
148 /// The stamp with nothing about the binary in it.
149 fn portable() -> Self {
150 Self {
151 format: FORMAT,
152 htl: env!("CARGO_PKG_VERSION").to_string(),
153 checker: crate::checker_identity().to_string(),
154 exe_len: 0,
155 exe_mtime_ns: 0,
156 }
157 }
158}
159
160/// One file the check read, by content.
161#[derive(Serialize, Deserialize, Debug)]
162struct Input {
163 path: String,
164 hash: String,
165}
166
167/// One directory a `require` could have resolved in, and what it offered *for the names
168/// this module asked for*.
169///
170/// This catches a file appearing where the checker would now find it, which no hash of the
171/// files it did read can see — the hole ccache documents in its direct mode. It used to
172/// hash every module name in the directory, which meant adding any file at all invalidated
173/// every module whose search path included it: writing one new module re-checked the whole
174/// project (#24). A file appearing under a name nobody requires cannot change what anybody
175/// resolved, so only the requested names go in.
176#[derive(Serialize, Deserialize, Debug)]
177struct Probe {
178 dir: String,
179 /// Hash of `(name, whether it resolves here)` over the module's own requires, in the
180 /// order they are stored. Changing which directory a name resolves in changes this for
181 /// both directories involved, which is how a shadowing file is caught.
182 names: String,
183}
184
185/// One diagnostic exactly as it was handed to the sink, so a replay goes through the same
186/// printing code the original run did rather than through a reconstruction of it.
187/// Reconstructing text from parsed fields is how a cache starts printing subtly different
188/// output from the run it claims to reproduce.
189#[derive(Serialize, Deserialize, Debug, Clone)]
190pub struct Recorded {
191 /// [`Severity::as_str`](crate::Severity::as_str)'s word. A string rather than the enum
192 /// because an entry outlives the build that wrote it, and a word this build does not
193 /// know has to read back as itself rather than fail the whole entry.
194 pub severity: String,
195 /// The finished line, position prefix and `[htl <rule>]` suffix included — what the
196 /// sink was handed, not what it was assembled from.
197 pub text: String,
198 /// The fix the diagnostic carried, for `htl fix` replaying instead of re-checking.
199 /// `None` when the diagnostic had none, which is most of them.
200 pub fix: Option<FixJson>,
201 /// Set when the text is an error in a module this one required rather than in this
202 /// one: the dependency and the file that required it. The entry carries every such
203 /// error the check found; the sink decides at replay, as it did at the original run,
204 /// which of them to say (once per run). Absent in entries written before this field
205 /// existed, which then read as having none.
206 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub dependency: Option<DependencyJson>,
208}
209
210/// A [`Fix`] as an entry (and the CLI's `--format json`) stores it.
211#[derive(Serialize, Deserialize, Debug, Clone)]
212pub struct FixJson {
213 /// `safe` / `unsafe` / `suggest`. Owned rather than `&'static str` because the store
214 /// reads these back, and a borrowed field cannot be deserialized into. The JSON is
215 /// unchanged either way.
216 pub applicability: String,
217 /// The edits, in the order [`Fix`] holds them. A fix is all of them or none: applying
218 /// part of one leaves the file in a state nobody asked for.
219 pub edits: Vec<EditJson>,
220}
221
222/// One replacement in a [`FixJson`]: a half-open span and what goes there.
223///
224/// Positions are the checker's — lines and columns counted from 1 — rather than byte
225/// offsets, because that is what the diagnostic beside it says and an entry that stored
226/// them differently would have to be trusted to convert the same way twice.
227#[derive(Serialize, Deserialize, Debug, Clone)]
228pub struct EditJson {
229 /// First line of the span.
230 pub line: usize,
231 /// First column of the span, inclusive.
232 pub col: usize,
233 /// Last line of the span; the same as `line` for an edit inside one line.
234 pub end_line: usize,
235 /// Column the span stops before, exclusive — so an empty span is an insertion.
236 pub end_col: usize,
237 /// What replaces the span. Empty to delete it.
238 pub text: String,
239}
240
241impl FixJson {
242 /// A fix as an entry stores it, for the run that writes one.
243 pub fn from_fix(f: &Fix) -> Self {
244 Self {
245 applicability: f.applicability.as_str().to_string(),
246 edits: f
247 .edits
248 .iter()
249 .map(|e| EditJson {
250 line: e.line,
251 col: e.col,
252 end_line: e.end_line,
253 end_col: e.end_col,
254 text: e.text.clone(),
255 })
256 .collect(),
257 }
258 }
259
260 /// And back, for a replayed one. An applicability this build does not know reads as
261 /// the most cautious of the three rather than failing the entry — see the match below.
262 pub fn to_fix(&self) -> Fix {
263 Fix {
264 applicability: match self.applicability.as_str() {
265 "unsafe" => crate::Applicability::Unsafe,
266 "suggest" => crate::Applicability::Suggest,
267 // Anything else is a build that wrote a name this one does not know;
268 // treating it as the most cautious of the three is the only safe reading.
269 "safe" => crate::Applicability::Safe,
270 _ => crate::Applicability::Suggest,
271 },
272 edits: self
273 .edits
274 .iter()
275 .map(|e| crate::Edit {
276 line: e.line,
277 col: e.col,
278 end_line: e.end_line,
279 end_col: e.end_col,
280 text: e.text.clone(),
281 })
282 .collect(),
283 }
284 }
285}
286
287/// What a dependency's diagnostic carries besides its text: the file that required it
288/// and where the file lives. Stored with the diagnostic ([`Recorded`]) so a replay says
289/// exactly what the run said, and decides the same way whether to say it.
290#[derive(Serialize, Deserialize, Debug, Clone)]
291pub struct DependencyJson {
292 /// The file the error is in, as the checker found it.
293 pub file: String,
294 /// The file whose `require` pulled it in — which is the one the reader is looking at,
295 /// and so the one a message about somebody else's file has to name.
296 pub required_by: String,
297 /// `dependency` / `external`, or none for a file of the project's own.
298 #[serde(default, skip_serializing_if = "Option::is_none")]
299 pub origin: Option<String>,
300}
301
302/// One literal `require` and where the checker resolved it. Kept because the project-level
303/// cycle lint runs over every file's requires, replayed ones included — a cycle that closes
304/// through a module nobody edited is still a cycle.
305#[derive(Serialize, Deserialize, Debug, Clone)]
306pub struct RequireJson {
307 /// The name as the source spells it.
308 pub module: String,
309 /// What it resolved to, `None` when the checker found nothing — a host module, or a
310 /// require that will fail. Stored either way: the cycle lint reads the resolved ones,
311 /// and a name that starts resolving is a change this entry has to notice.
312 pub path: Option<String>,
313 /// Where the `require` is, so a finding about it points at the call rather than the
314 /// file.
315 pub line: usize,
316 /// Column of the same.
317 pub col: usize,
318}
319
320/// What checking one module reported.
321#[derive(Serialize, Deserialize, Debug, Clone)]
322pub struct Module {
323 /// Its diagnostics, its contract lints included, in the order they were printed.
324 pub diagnostics: Vec<Recorded>,
325 /// How many of `diagnostics` were errors, counted when the entry was written.
326 ///
327 /// Stored rather than recounted on replay: the three are what a run adds up to decide
328 /// its exit code, and a replay that recounted them would be deciding that from its own
329 /// reading of the severity strings instead of from what the original run concluded.
330 pub errors: usize,
331 /// Warnings, as `errors`.
332 pub warnings: usize,
333 /// Lints, as `errors`.
334 pub lints: usize,
335 /// What the checker resolved this module's requires to. The next run keys on these,
336 /// which is how a dependency's edit invalidates its dependents.
337 pub deps: Vec<String>,
338 /// Every `require` in the source and where it went, which `deps` is the resolved,
339 /// deduplicated half of. Kept whole because the cycle lint reports a call site, and a
340 /// path cannot say which line asked for it.
341 pub requires: Vec<RequireJson>,
342 /// The Lua this module generates, for entries under [`gen_key`]. `htl check` never needs
343 /// it and stores `None`; `htl test` stores it so a replay can go straight to running.
344 /// Absent when checking produced errors, since there is nothing to run then.
345 #[serde(default)]
346 pub code: Option<String>,
347 /// What checking reported, in the form the test runner needs it back.
348 ///
349 /// `htl check` replays `diagnostics` straight into the sink and never reconstructs a
350 /// `CheckInfo`; the test runner puts one into its report, so a replayed test file needs
351 /// the structured form rather than the printed one. Only `gen_key` entries carry it.
352 #[serde(default)]
353 pub check: Option<CheckInfoJson>,
354}
355
356/// A `CheckInfo` as an entry stores it.
357///
358/// Deliberately a separate type from the diagnostics `htl check` replays: those are text
359/// on their way to a terminal, these are the fields the runner reads back.
360#[derive(Serialize, Deserialize, Debug, Clone, Default)]
361pub struct CheckInfoJson {
362 /// [`CheckInfo::errors`], verbatim.
363 pub errors: Vec<String>,
364 /// [`CheckInfo::warnings`], verbatim.
365 pub warnings: Vec<String>,
366 /// [`CheckInfo::lints`], verbatim.
367 pub lints: Vec<String>,
368 /// [`CheckInfo::deps`] as strings. `PathBuf` does not round-trip through JSON on every
369 /// platform; these are normalised on the way in and rebuilt on the way out.
370 pub deps: Vec<String>,
371 /// [`CheckInfo::requires`], in this module's own JSON shape.
372 pub requires: Vec<RequireJson>,
373 /// Parallel to `errors`, as in [`CheckInfo::error_fixes`]: `error_fixes[i]` belongs to
374 /// `errors[i]`. Stored as a full-length vector of `Option` rather than as the fixes
375 /// that exist, so the pairing survives without an index.
376 pub error_fixes: Vec<Option<FixJson>>,
377 /// Parallel to `lints`, as `error_fixes` is to `errors`.
378 pub lint_fixes: Vec<Option<FixJson>>,
379 /// `CheckInfo::dependency_errors`, so the runner reads back the whole of what the
380 /// check said. Absent in entries written before the field existed.
381 #[serde(default)]
382 pub dependency_errors: Vec<DependencyErrorJson>,
383}
384
385impl CheckInfoJson {
386 /// What a check reported, in the form an entry stores it.
387 pub fn from_check(c: &CheckInfo) -> Self {
388 Self {
389 errors: c.errors.clone(),
390 warnings: c.warnings.clone(),
391 lints: c.lints.clone(),
392 deps: c.deps.iter().map(|p| normal(p)).collect(),
393 requires: requires_json(c),
394 error_fixes: c
395 .error_fixes
396 .iter()
397 .map(|f| f.as_ref().map(FixJson::from_fix))
398 .collect(),
399 lint_fixes: c
400 .lint_fixes
401 .iter()
402 .map(|f| f.as_ref().map(FixJson::from_fix))
403 .collect(),
404 dependency_errors: c
405 .dependency_errors
406 .iter()
407 .map(|e| DependencyErrorJson {
408 file: e.file.display().to_string(),
409 required_by: e.required_by.display().to_string(),
410 text: e.text.clone(),
411 })
412 .collect(),
413 }
414 }
415
416 /// And back, for a replayed module: the test runner puts it into its report, the
417 /// linker reads its lints, requires and dependency errors.
418 pub fn to_check(&self) -> CheckInfo {
419 CheckInfo {
420 errors: self.errors.clone(),
421 warnings: self.warnings.clone(),
422 lints: self.lints.clone(),
423 deps: self.deps.iter().map(PathBuf::from).collect(),
424 requires: requires_from_json(&self.requires),
425 error_fixes: self
426 .error_fixes
427 .iter()
428 .map(|f| f.as_ref().map(FixJson::to_fix))
429 .collect(),
430 lint_fixes: self
431 .lint_fixes
432 .iter()
433 .map(|f| f.as_ref().map(FixJson::to_fix))
434 .collect(),
435 dependency_errors: self
436 .dependency_errors
437 .iter()
438 .map(|e| DependencyError {
439 file: PathBuf::from(&e.file),
440 required_by: PathBuf::from(&e.required_by),
441 text: e.text.clone(),
442 })
443 .collect(),
444 }
445 }
446}
447
448/// One `htl::DependencyError` as an entry stores it.
449#[derive(Serialize, Deserialize, Debug, Clone)]
450pub struct DependencyErrorJson {
451 /// The required module the error is in.
452 pub file: String,
453 /// The file whose `require` reached it — the one being checked, and the one a report
454 /// names.
455 pub required_by: String,
456 /// The error as the dependency's own check phrased it.
457 pub text: String,
458}
459
460/// `CheckInfo`'s requires in the form an entry stores them.
461pub fn requires_json(c: &CheckInfo) -> Vec<RequireJson> {
462 c.requires
463 .iter()
464 .map(|r| RequireJson {
465 module: r.module.clone(),
466 path: r.path.as_ref().map(|p| p.to_string_lossy().into_owned()),
467 line: r.line,
468 col: r.col,
469 })
470 .collect()
471}
472
473fn requires_from_json(requires: &[RequireJson]) -> Vec<RequireSite> {
474 requires
475 .iter()
476 .map(|r| RequireSite {
477 module: r.module.clone(),
478 path: r.path.as_ref().map(PathBuf::from),
479 line: r.line,
480 col: r.col,
481 })
482 .collect()
483}
484
485impl Module {
486 /// A `CheckInfo` carrying only what the project-level lints read.
487 ///
488 /// `require_cycles` runs over every file in the walk, replayed ones included — a cycle
489 /// that closes through a module nobody edited is still a cycle — so a replayed module
490 /// has to produce something that lint can read. Its diagnostics are already printed by
491 /// then, and nothing downstream looks at the other fields.
492 pub fn requires_only(&self) -> CheckInfo {
493 CheckInfo {
494 deps: self.deps.iter().map(PathBuf::from).collect(),
495 requires: requires_from_json(&self.requires),
496 ..Default::default()
497 }
498 }
499
500 /// A `gen` entry as the linker and the test runner write it: no printed diagnostics
501 /// (they print from the structured form), the generated Lua, and what the check said.
502 pub fn generated(c: &CheckInfo, code: String) -> Self {
503 Self {
504 diagnostics: Vec::new(),
505 errors: c.errors.len(),
506 warnings: c.warnings.len(),
507 lints: c.lints.len(),
508 deps: c.deps.iter().map(|p| normal(p)).collect(),
509 requires: requires_json(c),
510 code: Some(code),
511 check: Some(CheckInfoJson::from_check(c)),
512 }
513 }
514}
515
516/// Directories a `require` could resolve in, listed whether or not they exist yet.
517///
518/// The ones that do not exist matter most: a `types/` created after an entry was written
519/// changes what a module name resolves to while every file the entry recorded still hashes
520/// the same. Recording only the directories that happened to exist is the hole ccache
521/// documents in its direct mode, and an empty directory hashes differently from one holding
522/// the name, so a probe over a directory that is not there yet is what catches its arrival.
523///
524/// `cfg` is the project's `htl.toml` with the directory it was found in, for the extra
525/// `[check] paths` it names.
526///
527/// In an `mlua-pkg.toml` project the directories its installed deps resolve from are
528/// listed too — the same ones `Htl::apply_project` puts on the path — so that `htl pkg
529/// install` bringing a dependency in, after an entry recorded that the name resolved
530/// nowhere, is seen as the change it is rather than replayed as `module not found`.
531pub fn search_dirs(
532 file: &Path,
533 root: &Path,
534 cfg: Option<(&Path, &crate::config::HtlConfig)>,
535) -> Vec<PathBuf> {
536 let mut out: Vec<PathBuf> = file.parent().map(Path::to_path_buf).into_iter().collect();
537 out.push(root.to_path_buf());
538 out.push(root.join("src"));
539 out.push(root.join("types"));
540 out.extend(crate::materialised_types_dirs(&root.join("types")));
541 if let Some((r, c)) = cfg {
542 out.extend(c.search_paths(r));
543 }
544 out.extend(crate::dependency_dirs(root));
545 out
546}
547
548#[derive(Serialize, Deserialize, Debug)]
549struct Entry {
550 stamp: Stamp,
551 /// The file this entry is about, and which question it answers.
552 ///
553 /// Neither is needed to use the entry — the key already decided both. They are here so
554 /// the store can be described: the files on disk are named by hash, and a cache nobody
555 /// can read the contents of is one nobody can reason about (`htl cache status`).
556 subject: String,
557 kind: String,
558 inputs: Vec<Input>,
559 probes: Vec<Probe>,
560 module: Module,
561}
562
563/// One entry holding the whole walk, for `Mode::WholeRun`.
564#[derive(Serialize, Deserialize, Debug)]
565struct RunEntry {
566 stamp: Stamp,
567 /// The files the walk visited, and which question this answers. See `Entry`.
568 subjects: Vec<String>,
569 kind: String,
570 inputs: Vec<Input>,
571 probes: Vec<Probe>,
572 /// One per file in the walk, in walk order. A different count means the walk itself
573 /// changed, which is a miss before any hash is compared.
574 modules: Vec<Module>,
575}
576
577/// How much of a run one entry covers.
578///
579/// The two are a real trade rather than one being a refinement of the other, and which
580/// wins depends on where in the dependency graph the edit lands — see the Caching section
581/// of the README. `PerModule` is the default because editing is the common case.
582#[derive(Copy, Clone, PartialEq, Eq, Debug, Default)]
583pub enum Mode {
584 /// One entry per module. An edit costs the module, its dependents, and what those pull
585 /// in; everything else replays.
586 #[default]
587 PerModule,
588 /// One entry for the walk. Any edit anywhere re-checks everything, but a run where
589 /// nothing moved reads one file instead of one per module.
590 WholeRun,
591}
592
593impl Mode {
594 /// The mode a flag or an environment variable names, or `None` for a word this build
595 /// does not know — which the caller reports as a bad argument rather than silently
596 /// taking the default for.
597 pub fn parse(s: &str) -> Option<Self> {
598 match s {
599 "per-module" => Some(Mode::PerModule),
600 "whole-run" => Some(Mode::WholeRun),
601 _ => None,
602 }
603 }
604
605 /// The word [`parse`](Self::parse) reads, and the one a run's summary line prints. The
606 /// two are one spelling on purpose, so what a report says can be pasted back as a flag.
607 pub fn as_str(self) -> &'static str {
608 match self {
609 Mode::PerModule => "per-module",
610 Mode::WholeRun => "whole-run",
611 }
612 }
613}
614
615/// Everything the store needs from outside itself.
616///
617/// Built once, by the caller that is already reading flags and the environment, and passed
618/// in. Nothing in this module reaches for `std::env`: a run's behaviour is decided in one
619/// place, so reading that place tells you what the run will do.
620#[derive(Copy, Clone, Debug)]
621pub struct Options {
622 /// `--no-cache` turns this off. Separate from how the cache is grained.
623 pub enabled: bool,
624 /// How much of a run one entry covers. Held here rather than decided per lookup, so
625 /// the whole run reads and writes the same grain.
626 pub mode: Mode,
627 /// Say why lookups missed, and what the run did with the store.
628 pub explain: bool,
629 /// Entries a project's store may hold. `None` scales it to the project size.
630 pub max_entries: Option<usize>,
631}
632
633impl Default for Options {
634 fn default() -> Self {
635 Self {
636 enabled: true,
637 mode: Mode::default(),
638 explain: false,
639 max_entries: None,
640 }
641 }
642}
643
644/// What one run did with the store.
645///
646/// Kept by the store rather than by its callers, so a report cannot claim a number the store
647/// disagrees with.
648#[derive(Default, Copy, Clone, Debug)]
649pub struct Stats {
650 /// Modules replayed from an entry.
651 pub hits: usize,
652 /// Modules the store had nothing usable for, so they were checked. Counts a key that
653 /// was not there and an entry whose inputs had moved alike — both cost the same check,
654 /// and `explain` is what tells them apart.
655 pub misses: usize,
656 /// Entries written. Not the same as `misses`: a run with writing off, or one whose
657 /// write failed, misses without storing.
658 pub stored: usize,
659 /// Entries the sweep dropped, which only `htl check` does.
660 pub evicted: usize,
661}
662
663impl Stats {
664 /// One line, for the end of a run. `None` when the run did not touch the store at all,
665 /// which is worth saying by saying nothing.
666 pub fn summary(&self, mode: Mode) -> Option<String> {
667 let touched = self.hits + self.misses + self.stored + self.evicted;
668 (touched > 0).then(|| {
669 format!(
670 "htl cache: {} hit, {} missed, {} written, {} evicted ({})",
671 self.hits,
672 self.misses,
673 self.stored,
674 self.evicted,
675 mode.as_str()
676 )
677 })
678 }
679}
680
681/// The identity of one module under one invocation: same key, same question.
682///
683/// Carries which question it is, so an entry written under it can record that too — the file
684/// on disk is named by the hash, and a store whose contents cannot be described is one
685/// nobody can reason about.
686#[derive(Debug, Clone)]
687pub struct Key {
688 hash: String,
689 kind: &'static str,
690}
691
692/// Hash of a file's contents, or `None` if it cannot be read — an unreadable input is a
693/// miss, never a silent hit.
694fn hash_file(p: &Path) -> Option<String> {
695 let bytes = std::fs::read(p).ok()?;
696 Some(blake3::hash(&bytes).to_hex().to_string())
697}
698
699/// A path in the form it is stored and compared in: absolute and symlink-resolved where
700/// possible.
701///
702/// Normalizing at one producer and not another is a real bug rather than a tidiness
703/// concern: mypy shipped a version where a file checked directly recorded a relative path
704/// while the same file reached as an import recorded an absolute one, so its hash depended
705/// on how it was reached. Everything that goes into an entry comes through here.
706pub fn normal(p: &Path) -> String {
707 std::fs::canonicalize(p)
708 .unwrap_or_else(|_| p.to_path_buf())
709 .to_string_lossy()
710 .into_owned()
711}
712
713/// Whether `name` resolves to a file in `dir`.
714///
715/// The shapes are the ones `H.add_path` puts on the search path (`dir/?.tl`,
716/// `dir/?/init.tl`) plus the declaration and Lua forms the checker falls back to. A dot in
717/// a module name is a directory separator, as it is for Lua's own searcher.
718///
719/// This is an existence question, not a resolution: it says the name *could* be found here,
720/// and comparing the answer across every directory on the path is what makes a change of
721/// resolution visible. Getting it wrong in the direction of "present" costs a re-check;
722/// there is no direction in which it produces a wrong answer.
723fn resolves_in(dir: &Path, name: &str) -> bool {
724 let stem = name.replace('.', "/");
725 ["tl", "d.tl", "lua"]
726 .iter()
727 .any(|ext| dir.join(format!("{stem}.{ext}")).is_file())
728 || ["init.tl", "init.d.tl", "init.lua"]
729 .iter()
730 .any(|f| dir.join(&stem).join(f).is_file())
731}
732
733/// The key for one module in this invocation.
734///
735/// `spelling` is the path as the CLI will hand it to the checker, not a resolved one: the
736/// checker echoes it into every diagnostic it prints, so `htl check .` and
737/// `htl check src/a.tl` produce different text for the same module and cannot share an
738/// entry. The working directory completes it, since the same relative spelling means a
739/// different file from somewhere else.
740pub fn module_key(spelling: &Path, lint: Option<&str>) -> Key {
741 key_with(CHECK, spelling, lint)
742}
743
744/// An entry holding what checking a module reported.
745pub const CHECK: &str = "check";
746/// An entry holding that, plus the Lua the module generates.
747pub const GEN: &str = "gen";
748
749/// The key for one test file's checked-and-generated form.
750///
751/// The same material as [`module_key`] under a different prefix, so the two never collide.
752/// They are separate entries on purpose: a `htl check` entry holds diagnostics and nothing
753/// else, and making it carry a few KB of generated Lua would cost every check run a parse it
754/// has no use for.
755pub fn gen_key(spelling: &Path, lint: Option<&str>) -> Key {
756 key_with(GEN, spelling, lint)
757}
758
759/// An entry holding a required module's generated Lua, keyed by the file it is.
760pub const MODULE: &str = "module";
761
762/// The key for a module's checked-and-generated form, by the file it is.
763///
764/// A test file is keyed by its spelling ([`gen_key`]) because the runner prints that
765/// spelling. A module reached through `require` is another matter: the test runner meets
766/// it by the path the checker resolved, the linker by the one it resolved, `htl build` and
767/// `include_bundle!` from different working directories — and its generated Lua is the
768/// same file's Lua whichever way it was reached. So the key is the canonical path and the
769/// lint selection, and nothing about the invocation, and the entry is stamped without the
770/// binary (the entry's stamp): that is what lets `htl test`, `htl build` and the macros replay one
771/// another's entries (#100).
772pub fn module_gen_key(path: &Path, lint: Option<&str>) -> Key {
773 let mut h = blake3::Hasher::new();
774 h.update(MODULE.as_bytes());
775 h.update(b"\0");
776 h.update(normal(path).as_bytes());
777 h.update(b"\0");
778 h.update(lint.unwrap_or("").as_bytes());
779 Key {
780 hash: h.finalize().to_hex().to_string(),
781 kind: MODULE,
782 }
783}
784
785/// Whether a module's source could `require` anything: the token appears in it.
786///
787/// `gen_lua` comes back without requires for a module the checker already holds — it
788/// serves the generated code without walking the file again — and a reader that needs the
789/// requires (the linker's walk, an entry's validation) asks `check` for them. A full check
790/// for every leaf module is the wrong price for that, and a file that never says
791/// `require` has nothing to ask about. Word-bounded, so `required_by` does not count.
792pub fn source_mentions_require(text: &str) -> bool {
793 let bytes = text.as_bytes();
794 let ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
795 text.match_indices("require").any(|(i, _)| {
796 let before = i.checked_sub(1).map(|j| bytes[j]);
797 let after = bytes.get(i + "require".len()).copied();
798 !before.is_some_and(ident) && !after.is_some_and(ident)
799 })
800}
801
802/// The switches every reader of the store honours, from the environment:
803/// `HTL_NO_CACHE` (neither read nor write), `HTL_CACHE_DEBUG` (say why lookups missed),
804/// `HTL_CACHE_MAX_ENTRIES` (the sweep's bound; the test suite's, since nothing else can
805/// reach a few hundred entries by honest means). A command's flags override what this
806/// read; the proc macros have no flags and take this as it is.
807impl Options {
808 /// The options the environment asks for, before any flag is applied.
809 pub fn from_env() -> Self {
810 Self {
811 enabled: std::env::var_os("HTL_NO_CACHE").is_none(),
812 mode: Mode::PerModule,
813 explain: std::env::var_os("HTL_CACHE_DEBUG").is_some(),
814 max_entries: std::env::var_os("HTL_CACHE_MAX_ENTRIES")
815 .and_then(|v| v.to_str().and_then(|s| s.parse().ok())),
816 }
817 }
818}
819
820/// Where a project's store lives: beside the `htl.toml` found from `path`, or nowhere.
821///
822/// One project, one store: every reader — `htl check`, `htl test`, `htl build`, the proc
823/// macros — resolves it through here, so they find each other's entries. A directory with
824/// no `htl.toml` has not opted into the layout (`htl init` / `htl new` write the file and
825/// gitignore `.htl/`); the CLI falls back to the working directory for its own commands,
826/// the macros to no store at all.
827pub fn root_for(path: &Path) -> Option<PathBuf> {
828 crate::config::HtlConfig::find(path)
829 .ok()
830 .flatten()
831 .map(|(file, _)| crate::parent_dir(&file))
832}
833
834/// Why nothing may be written under `root`, if nothing may — build scratch, named.
835///
836/// A macro expands wherever cargo compiles the crate: in the checkout, but also in the
837/// copy `cargo publish` verifies under `target/package/<crate>/` — where a new file makes
838/// cargo abort with "Source directory was modified" — and in a registry checkout under
839/// `$CARGO_HOME/registry/src`, which nothing should write to. Both are recognisable by
840/// their path, and neither is anybody's tree to keep anything in.
841///
842/// **The rule is the whole `.htl/`, not the store.** Every write htl makes as a side
843/// effect of reading a project asks here first, and there are two: the run cache
844/// (`project::store_refusal`, which every reader of the store comes through) and the
845/// entry links ([`crate::pkg::Project::link_entries`], through
846/// [`Htl::apply_project`](crate::Htl::apply_project)). The link repair was outside the
847/// rule until #267, where it wrote `.htl/modules/entries/<dep>` into the tree `cargo
848/// package` had just built and cargo refused the tarball. A reader of a scratch tree
849/// reads it exactly as it was packaged.
850///
851/// **`target` is recognised by the path and nothing else.** cargo publishes no signal
852/// saying "this build is the verification step", so htl reads the convention instead: a
853/// path component named `target`. That has two costs, both accepted for having one rule
854/// in one function rather than one per writer. A `CARGO_TARGET_DIR` pointing outside the
855/// tree defeats it — the verify copy is then not under a `target` directory, and htl
856/// writes in it as it would in a checkout. And a project that keeps its own sources under
857/// a directory called `target` goes without a store it could have had; a miss costs a
858/// check, which is the cheaper way to be wrong.
859pub fn scratch_root(root: &Path) -> Option<&'static str> {
860 if root.components().any(|c| c.as_os_str() == "target") {
861 return Some("under a `target` directory");
862 }
863 let cargo_home = std::env::var_os("CARGO_HOME")
864 .map(PathBuf::from)
865 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cargo")));
866 if let Some(registry) = cargo_home.map(|h| h.join("registry"))
867 && root.starts_with(®istry)
868 {
869 return Some("a registry checkout");
870 }
871 None
872}
873
874fn key_with(kind: &'static str, spelling: &Path, lint: Option<&str>) -> Key {
875 let mut h = blake3::Hasher::new();
876 h.update(kind.as_bytes());
877 h.update(b"\0");
878 h.update(spelling.to_string_lossy().as_bytes());
879 h.update(b"\0");
880 h.update(lint.unwrap_or("").as_bytes());
881 h.update(b"\0");
882 h.update(cwd().as_bytes());
883 Key {
884 hash: h.finalize().to_hex().to_string(),
885 kind,
886 }
887}
888
889/// The key for the walk as a whole, under `Mode::WholeRun`.
890///
891/// Every file's spelling goes in, in order: a walk that visits a different set, or the same
892/// set named differently, is a different run and prints different text.
893pub fn run_key(files: &[PathBuf], lint: Option<&str>) -> Key {
894 let mut h = blake3::Hasher::new();
895 h.update(b"htl-run\0");
896 for f in files {
897 h.update(f.to_string_lossy().as_bytes());
898 h.update(b"\0");
899 }
900 h.update(lint.unwrap_or("").as_bytes());
901 h.update(b"\0");
902 h.update(cwd().as_bytes());
903 Key {
904 hash: h.finalize().to_hex().to_string(),
905 kind: RUN,
906 }
907}
908
909/// One entry covering a whole walk (`Mode::WholeRun`).
910pub const RUN: &str = "run";
911
912/// The working directory, resolved once. This is called for every module in the walk, and
913/// resolving it means a `canonicalize` syscall — doing that per module is a measurable part
914/// of a run that replays everything and does nothing else.
915fn cwd() -> &'static str {
916 static CWD: std::sync::OnceLock<String> = std::sync::OnceLock::new();
917 CWD.get_or_init(|| {
918 std::env::current_dir()
919 .map(|p| normal(&p))
920 .unwrap_or_default()
921 })
922}
923
924/// One entry, as `htl cache status` describes it.
925#[derive(Serialize, Debug)]
926pub struct EntrySummary {
927 /// `check`, `gen`, `run` — or `unreadable` for a file this build cannot parse, which is
928 /// worth showing rather than hiding, since it is also a permanent miss.
929 pub kind: String,
930 /// The files it is about. One, except for a whole-run entry.
931 pub subjects: Vec<String>,
932 /// Size of the entry's file on disk, which is what `htl cache status` totals.
933 pub bytes: u64,
934 /// Seconds since the entry was last written or replayed. The sweep drops the oldest.
935 pub age_secs: u64,
936}
937
938/// What a project's store holds.
939#[derive(Serialize, Debug)]
940pub struct Contents {
941 /// The store's directory, said even when it holds nothing — "empty" and "not where you
942 /// thought" are different answers and a reader cannot tell them apart without it.
943 pub dir: String,
944 /// One per entry file. Empty when the directory is missing or unreadable, which are
945 /// not distinguished: neither is a store this run can use.
946 pub entries: Vec<EntrySummary>,
947 /// The whole store's bytes, summed over `entries`.
948 pub bytes: u64,
949}
950
951/// Read the store and say what is in it.
952///
953/// Every entry is parsed, which is why this is a command rather than something a run does on
954/// the side. Nothing here validates: an entry listed may still be a miss on the next run
955/// because its inputs moved. This answers "what is stored", not "what would be reused".
956pub fn describe(root: &Path) -> Contents {
957 let dir = root.join(DIR);
958 let mut out = Contents {
959 dir: dir.to_string_lossy().into_owned(),
960 entries: Vec::new(),
961 bytes: 0,
962 };
963 let Ok(rd) = std::fs::read_dir(&dir) else {
964 return out;
965 };
966 let now = std::time::SystemTime::now();
967 for e in rd.flatten() {
968 let path = e.path();
969 if path.extension().is_none_or(|x| x != "json") {
970 continue;
971 }
972 let Ok(meta) = e.metadata() else { continue };
973 out.bytes += meta.len();
974 let age_secs = meta
975 .modified()
976 .ok()
977 .and_then(|t| now.duration_since(t).ok())
978 .map(|d| d.as_secs())
979 .unwrap_or(0);
980 let v: serde_json::Value = std::fs::read_to_string(&path)
981 .ok()
982 .and_then(|raw| serde_json::from_str(&raw).ok())
983 .unwrap_or(serde_json::Value::Null);
984 let kind = v
985 .get("kind")
986 .and_then(|k| k.as_str())
987 .unwrap_or("unreadable")
988 .to_string();
989 let subjects = match (v.get("subject"), v.get("subjects")) {
990 (Some(s), _) if s.is_string() => vec![s.as_str().unwrap_or_default().to_string()],
991 (_, Some(a)) if a.is_array() => a
992 .as_array()
993 .unwrap()
994 .iter()
995 .filter_map(|x| x.as_str().map(str::to_string))
996 .collect(),
997 _ => Vec::new(),
998 };
999 out.entries.push(EntrySummary {
1000 kind,
1001 subjects,
1002 bytes: meta.len(),
1003 age_secs,
1004 });
1005 }
1006 out.entries
1007 .sort_by(|a, b| a.kind.cmp(&b.kind).then(a.subjects.cmp(&b.subjects)));
1008 out
1009}
1010
1011/// A store rooted at a project.
1012pub struct Cache {
1013 dir: PathBuf,
1014 opts: Options,
1015 stats: RefCell<Stats>,
1016 /// Content hashes taken during this run, by normalized path.
1017 ///
1018 /// Entries overlap heavily: every module in a project tends to require the same few
1019 /// leaves, so a leaf appears in the inputs of most entries. Without this, checking 48
1020 /// modules against the store reads and hashes the shared ones 48 times — which is most
1021 /// of what a fully replayed run spends. A file is assumed not to change while one
1022 /// `htl check` is running; if it does, the run's answer was undefined anyway.
1023 hashes: RefCell<HashMap<String, Option<String>>>,
1024 /// Name-resolves-here answers taken during this run, by (directory, name). Same
1025 /// reasoning.
1026 dirs: RefCell<HashMap<(String, String), bool>>,
1027}
1028
1029impl Cache {
1030 /// `None` when the caller turned caching off, or when this build cannot identify
1031 /// itself well enough to be sure an entry is its own.
1032 pub fn open(root: &Path, opts: Options) -> Option<Self> {
1033 if !opts.enabled {
1034 return None;
1035 }
1036 Stamp::current()?;
1037 Some(Self {
1038 dir: root.join(DIR),
1039 opts,
1040 stats: RefCell::new(Stats::default()),
1041 hashes: RefCell::new(HashMap::new()),
1042 dirs: RefCell::new(HashMap::new()),
1043 })
1044 }
1045
1046 /// What this run has done with the store so far.
1047 pub fn stats(&self) -> Stats {
1048 *self.stats.borrow()
1049 }
1050
1051 /// Say why something was not reused. Only when asked; the caller decided that once.
1052 fn miss(&self, reason: &str) {
1053 self.stats.borrow_mut().misses += 1;
1054 if self.opts.explain {
1055 eprintln!("htl cache: miss ({reason})");
1056 }
1057 }
1058
1059 /// The hash of a file, taken once per run.
1060 fn hash_of(&self, path: &str) -> Option<String> {
1061 if let Some(h) = self.hashes.borrow().get(path) {
1062 return h.clone();
1063 }
1064 let h = hash_file(Path::new(path));
1065 self.hashes.borrow_mut().insert(path.to_string(), h.clone());
1066 h
1067 }
1068
1069 /// Whether one name resolves in one directory, answered once per run. The project root,
1070 /// `src/` and `types/` are probed by every module, and modules share the names they
1071 /// require, so this overlaps even more than the file hashes do.
1072 fn resolves(&self, dir: &str, name: &str) -> bool {
1073 let k = (dir.to_string(), name.to_string());
1074 if let Some(v) = self.dirs.borrow().get(&k) {
1075 return *v;
1076 }
1077 let v = resolves_in(Path::new(dir), name);
1078 self.dirs.borrow_mut().insert(k, v);
1079 v
1080 }
1081
1082 /// What `dir` offers for `names`, hashed.
1083 fn probe_hash(&self, dir: &str, names: &[String]) -> String {
1084 let mut h = blake3::Hasher::new();
1085 for n in names {
1086 h.update(n.as_bytes());
1087 h.update(if self.resolves(dir, n) {
1088 b"\x01"
1089 } else {
1090 b"\x00"
1091 });
1092 }
1093 h.finalize().to_hex().to_string()
1094 }
1095
1096 fn entry_path(&self, key: &Key) -> PathBuf {
1097 self.dir.join(format!("{}.json", key.hash))
1098 }
1099
1100 /// The grain this store was opened with. A caller that decides between a per-module
1101 /// walk and a whole-run lookup asks the store rather than re-reading the flags, so the
1102 /// two cannot disagree.
1103 pub fn mode(&self) -> Mode {
1104 self.opts.mode
1105 }
1106
1107 /// A truncated or hand-edited entry is a miss, not a crash.
1108 fn parse<T: serde::de::DeserializeOwned>(&self, raw: &str) -> Option<T> {
1109 match serde_json::from_str(raw) {
1110 Ok(e) => Some(e),
1111 Err(e) => {
1112 self.miss(&format!("unreadable entry: {e}"));
1113 None
1114 }
1115 }
1116 }
1117
1118 /// The module names an entry's modules asked for, sorted and deduplicated.
1119 ///
1120 /// Both sides of a probe comparison have to derive this the same way, which is why it is
1121 /// one function rather than two loops.
1122 fn required_names(modules: &[Module]) -> Vec<String> {
1123 let mut names: Vec<String> = modules
1124 .iter()
1125 .flat_map(|m| m.requires.iter().map(|r| r.module.clone()))
1126 .collect();
1127 names.sort();
1128 names.dedup();
1129 names
1130 }
1131
1132 /// Whether an entry still describes the world: written by this build, every file it read
1133 /// unchanged, and every directory it could have resolved in still answering the same way
1134 /// for the names it asked about.
1135 fn still_valid(
1136 &self,
1137 kind: &str,
1138 stamp: &Stamp,
1139 inputs: &[Input],
1140 probes: &[Probe],
1141 names: &[String],
1142 ) -> bool {
1143 let Some(current) = Stamp::for_kind(kind) else {
1144 return false;
1145 };
1146 if *stamp != current {
1147 self.miss("written by a different build");
1148 return false;
1149 }
1150 for i in inputs {
1151 match self.hash_of(&i.path) {
1152 Some(h) if h == i.hash => {}
1153 Some(_) => {
1154 self.miss(&format!("changed: {}", i.path));
1155 return false;
1156 }
1157 None => {
1158 self.miss(&format!("gone: {}", i.path));
1159 return false;
1160 }
1161 }
1162 }
1163 for p in probes {
1164 if self.probe_hash(&p.dir, names) != p.names {
1165 self.miss(&format!(
1166 "what {} offers for this module's requires changed",
1167 p.dir
1168 ));
1169 return false;
1170 }
1171 }
1172 self.stats.borrow_mut().hits += 1;
1173 true
1174 }
1175
1176 /// What each file in the walk reported last time; `None` where it has to be checked.
1177 ///
1178 /// Under `WholeRun` this is all-or-nothing by construction: one entry covers the walk,
1179 /// so a single changed input means every module is checked.
1180 pub fn lookup_all(&self, keys: &[Key], run: &Key, files: usize) -> Vec<Option<Module>> {
1181 match self.opts.mode {
1182 Mode::PerModule => keys.iter().map(|k| self.lookup(k)).collect(),
1183 Mode::WholeRun => match self.lookup_run(run, files) {
1184 Some(ms) => ms.into_iter().map(Some).collect(),
1185 None => vec![None; files],
1186 },
1187 }
1188 }
1189
1190 /// What the module reported last time, if every input and probe still matches.
1191 pub fn lookup(&self, key: &Key) -> Option<Module> {
1192 let raw = std::fs::read_to_string(self.entry_path(key)).ok()?;
1193 let entry: Entry = self.parse(&raw)?;
1194 let names = Self::required_names(std::slice::from_ref(&entry.module));
1195 if !self.still_valid(
1196 &entry.kind,
1197 &entry.stamp,
1198 &entry.inputs,
1199 &entry.probes,
1200 &names,
1201 ) {
1202 return None;
1203 }
1204 self.touch(key);
1205 Some(entry.module)
1206 }
1207
1208 /// Mark an entry as used, so that the sweep drops what is stale rather than what is
1209 /// merely old.
1210 ///
1211 /// Without this the shape you run most often is the one whose entries were written
1212 /// first, so it ages out while a lint flag you tried once survives — measured on a
1213 /// 58-module project, running four other shapes left the plain one replaying 24 of its
1214 /// 58 modules. Failing to touch costs a later re-check and nothing else, so every error
1215 /// here is ignored.
1216 fn touch(&self, key: &Key) {
1217 if let Ok(f) = std::fs::File::options()
1218 .write(true)
1219 .open(self.entry_path(key))
1220 {
1221 let _ = f.set_modified(std::time::SystemTime::now());
1222 }
1223 }
1224
1225 fn lookup_run(&self, key: &Key, files: usize) -> Option<Vec<Module>> {
1226 let raw = std::fs::read_to_string(self.entry_path(key)).ok()?;
1227 let entry: RunEntry = self.parse(&raw)?;
1228 if entry.modules.len() != files {
1229 self.miss("the walk visits a different number of files");
1230 return None;
1231 }
1232 let names = Self::required_names(&entry.modules);
1233 if !self.still_valid(RUN, &entry.stamp, &entry.inputs, &entry.probes, &names) {
1234 return None;
1235 }
1236 self.touch(key);
1237 Some(entry.modules)
1238 }
1239
1240 /// Hash every path an entry has to compare next time, once each: a dependency reached
1241 /// from two modules is compared once. `None` if anything is unreadable, which drops the
1242 /// entry rather than storing one that would hit wrongly.
1243 fn inputs_for(&self, paths: impl Iterator<Item = String>) -> Option<Vec<Input>> {
1244 let mut paths: Vec<String> = paths.collect();
1245 paths.sort();
1246 paths.dedup();
1247 let mut inputs = Vec::with_capacity(paths.len());
1248 for p in paths {
1249 inputs.push(Input {
1250 path: p.clone(),
1251 hash: self.hash_of(&p)?,
1252 });
1253 }
1254 Some(inputs)
1255 }
1256
1257 fn probes_for(&self, dirs: &[PathBuf], names: &[String]) -> Vec<Probe> {
1258 let mut dirs: Vec<String> = dirs.iter().map(|p| normal(p)).collect();
1259 dirs.sort();
1260 dirs.dedup();
1261 dirs.into_iter()
1262 .map(|d| {
1263 let h = self.probe_hash(&d, names);
1264 Probe { dir: d, names: h }
1265 })
1266 .collect()
1267 }
1268
1269 /// Record what one module reported. Best-effort: a store that cannot be written leaves
1270 /// the next run to do the work again, which is slow rather than wrong.
1271 pub fn store_module(
1272 &self,
1273 key: &Key,
1274 file: &Path,
1275 extra_inputs: &[PathBuf],
1276 dirs: &[PathBuf],
1277 module: &Module,
1278 ) {
1279 let Some(stamp) = Stamp::for_kind(key.kind) else {
1280 return;
1281 };
1282 let paths = std::iter::once(normal(file))
1283 .chain(module.deps.iter().cloned())
1284 .chain(extra_inputs.iter().map(|p| normal(p)));
1285 let Some(inputs) = self.inputs_for(paths) else {
1286 return;
1287 };
1288 let names = Self::required_names(std::slice::from_ref(module));
1289 let entry = Entry {
1290 stamp,
1291 subject: normal(file),
1292 kind: key.kind.to_string(),
1293 inputs,
1294 probes: self.probes_for(dirs, &names),
1295 module: module.clone(),
1296 };
1297 match self.write(key, &entry) {
1298 Ok(()) => self.stats.borrow_mut().stored += 1,
1299 Err(e) if self.opts.explain => eprintln!("htl cache: not stored ({e})"),
1300 Err(_) => {}
1301 }
1302 }
1303
1304 /// Record the whole walk as one entry. Its inputs are the union of every module's, so
1305 /// any edit anywhere misses — which is the behaviour this mode is chosen for.
1306 pub fn store_run(
1307 &self,
1308 key: &Key,
1309 files: &[PathBuf],
1310 extra_inputs: &[PathBuf],
1311 dirs: &[PathBuf],
1312 modules: &[Module],
1313 ) {
1314 let Some(stamp) = Stamp::current() else {
1315 return;
1316 };
1317 let paths = files
1318 .iter()
1319 .map(|f| normal(f))
1320 .chain(modules.iter().flat_map(|m| m.deps.iter().cloned()))
1321 .chain(extra_inputs.iter().map(|p| normal(p)));
1322 let Some(inputs) = self.inputs_for(paths) else {
1323 return;
1324 };
1325 let names = Self::required_names(modules);
1326 let entry = RunEntry {
1327 stamp,
1328 subjects: files.iter().map(|f| normal(f)).collect(),
1329 kind: key.kind.to_string(),
1330 inputs,
1331 probes: self.probes_for(dirs, &names),
1332 modules: modules.to_vec(),
1333 };
1334 match self.write(key, &entry) {
1335 Ok(()) => self.stats.borrow_mut().stored += 1,
1336 Err(e) if self.opts.explain => eprintln!("htl cache: not stored ({e})"),
1337 Err(_) => {}
1338 }
1339 }
1340
1341 /// Drop entries this run did not use, once the store has outgrown what the project
1342 /// warrants.
1343 ///
1344 /// Nothing else removes an entry. The key covers the paths as written, the lint
1345 /// selection and the working directory, so every distinct way of invoking the check
1346 /// leaves a full set of module entries behind — a lint flag tried once doubles the store
1347 /// permanently, and a deleted module's entry is never read again. `htl check` sees the
1348 /// whole graph in one process, which is what lets this be exact about the orphans rather
1349 /// than sampling the way ccache has to.
1350 ///
1351 /// Two passes. Entries whose recorded inputs have all gone describe modules that no
1352 /// longer exist, and go first. If the store is still over, the oldest go until it fits.
1353 ///
1354 /// **This uses mtimes, and #3's rule against them still holds.** That rule is about
1355 /// invalidation, where trusting a timestamp means replaying a stale result and reporting
1356 /// something untrue. Dropping an entry that was still good costs the check it would have
1357 /// skipped and nothing else. The two questions deserve different tools.
1358 ///
1359 /// "Oldest" is least recently *used*: a hit touches its entry, which is what makes the
1360 /// two differ.
1361 /// Without that it would mean least recently written, and the shape run most often —
1362 /// written first — would age out while a shape tried once survived.
1363 pub fn sweep(&self, keep: &[Key], files: usize) {
1364 let bound = self
1365 .opts
1366 .max_entries
1367 .unwrap_or_else(|| default_bound(files));
1368 let Ok(rd) = std::fs::read_dir(&self.dir) else {
1369 return;
1370 };
1371 let keep: std::collections::HashSet<&str> = keep.iter().map(|k| k.hash.as_str()).collect();
1372
1373 let mut all = 0usize;
1374 let mut candidates: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
1375 for e in rd.flatten() {
1376 let path = e.path();
1377 if path.extension().is_none_or(|x| x != "json") {
1378 continue;
1379 }
1380 all += 1;
1381 let stem = path
1382 .file_stem()
1383 .map(|s| s.to_string_lossy().into_owned())
1384 .unwrap_or_default();
1385 if keep.contains(stem.as_str()) {
1386 continue;
1387 }
1388 let mtime = e
1389 .metadata()
1390 .and_then(|m| m.modified())
1391 .unwrap_or(std::time::UNIX_EPOCH);
1392 candidates.push((path, mtime));
1393 }
1394 if all <= bound {
1395 return;
1396 }
1397
1398 let mut removed = 0usize;
1399 // Orphans first: an entry none of whose inputs still exist cannot be read again.
1400 candidates.retain(|(p, _)| {
1401 if all - removed <= bound || !self.is_orphan(p) {
1402 return true;
1403 }
1404 if std::fs::remove_file(p).is_ok() {
1405 removed += 1;
1406 }
1407 false
1408 });
1409 // Then by age, oldest first, until the store fits.
1410 if all - removed > bound {
1411 candidates.sort_by_key(|(_, t)| *t);
1412 for (p, _) in &candidates {
1413 if all - removed <= bound {
1414 break;
1415 }
1416 if std::fs::remove_file(p).is_ok() {
1417 removed += 1;
1418 }
1419 }
1420 }
1421 self.stats.borrow_mut().evicted += removed;
1422 if removed > 0 && self.opts.explain {
1423 eprintln!("htl cache: dropped {removed} of {all} entries (bound {bound})");
1424 }
1425 }
1426
1427 /// Whether every file an entry recorded as an input has gone. Unreadable entries count as
1428 /// orphans: nothing can use them either.
1429 fn is_orphan(&self, path: &Path) -> bool {
1430 let Ok(raw) = std::fs::read_to_string(path) else {
1431 return true;
1432 };
1433 let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) else {
1434 return true;
1435 };
1436 let Some(inputs) = v.get("inputs").and_then(|i| i.as_array()) else {
1437 return true;
1438 };
1439 !inputs
1440 .iter()
1441 .filter_map(|i| i.get("path").and_then(|p| p.as_str()))
1442 .any(|p| Path::new(p).exists())
1443 }
1444
1445 /// Whole or not at all: a temporary file in the same directory, then a rename. On every
1446 /// filesystem htl runs on that rename is atomic, so a concurrent reader sees either the
1447 /// previous entry or this one, never half of this one. ccache relies on exactly this
1448 /// and takes no locks at all.
1449 fn write<T: Serialize>(&self, key: &Key, entry: &T) -> Result<()> {
1450 std::fs::create_dir_all(&self.dir)?;
1451 let final_path = self.entry_path(key);
1452 let tmp = self
1453 .dir
1454 .join(format!(".{}.{}.tmp", key.hash, std::process::id()));
1455 std::fs::write(&tmp, serde_json::to_vec(entry)?)?;
1456 if let Err(e) = std::fs::rename(&tmp, &final_path) {
1457 let _ = std::fs::remove_file(&tmp);
1458 return Err(e.into());
1459 }
1460 Ok(())
1461 }
1462}
1463
1464#[cfg(test)]
1465mod tests {
1466 use super::*;
1467
1468 fn scratch(name: &str) -> PathBuf {
1469 let dir =
1470 std::env::temp_dir().join(format!("htl-core-cache-{name}-{}", std::process::id()));
1471 std::fs::create_dir_all(dir.join("src")).unwrap();
1472 dir
1473 }
1474
1475 /// A `module` entry is stamped without the binary, so one written by `htl` is served
1476 /// inside a proc macro (whose binary is `rustc`) and the other way round. A test-file
1477 /// entry keeps the binary in its stamp, as it always did.
1478 #[test]
1479 fn a_module_entry_is_served_whatever_binary_wrote_it() {
1480 let root = scratch("portable");
1481 let file = root.join("src/m.tl");
1482 std::fs::write(&file, "return {}\n").unwrap();
1483 let cache = Cache::open(&root, Options::default()).unwrap();
1484 // What another binary writes for a module: the portable stamp. This binary's own
1485 // stamp is a different value, which is the point.
1486 assert_ne!(Stamp::current().unwrap(), Stamp::portable());
1487 let module = Module {
1488 diagnostics: Vec::new(),
1489 errors: 0,
1490 warnings: 0,
1491 lints: 0,
1492 deps: Vec::new(),
1493 requires: Vec::new(),
1494 code: Some("return {}".into()),
1495 check: Some(CheckInfoJson::default()),
1496 };
1497 let entry = |kind: &str| Entry {
1498 stamp: Stamp::portable(),
1499 subject: normal(&file),
1500 kind: kind.to_string(),
1501 inputs: cache.inputs_for(std::iter::once(normal(&file))).unwrap(),
1502 probes: Vec::new(),
1503 module: module.clone(),
1504 };
1505 let mk = module_gen_key(&file, None);
1506 cache.write(&mk, &entry(MODULE)).unwrap();
1507 assert!(
1508 cache.lookup(&mk).is_some(),
1509 "a module entry: the binary is not part of its stamp"
1510 );
1511 let gk = gen_key(&file, None);
1512 cache.write(&gk, &entry(GEN)).unwrap();
1513 assert!(
1514 cache.lookup(&gk).is_none(),
1515 "a test-file entry from another binary: written by a different build"
1516 );
1517 // And what this binary stores under a module key is what any other reads.
1518 cache.store_module(&mk, &file, &[], &[], &module);
1519 let raw = std::fs::read_to_string(cache.entry_path(&mk)).unwrap();
1520 let stored: Entry = serde_json::from_str(&raw).unwrap();
1521 assert_eq!(stored.stamp, Stamp::portable());
1522 }
1523
1524 #[test]
1525 fn source_mentions_require_is_word_bounded() {
1526 assert!(source_mentions_require("local a = require(\"a\")\n"));
1527 assert!(source_mentions_require("require 'a'"));
1528 assert!(!source_mentions_require("local required_by = 1\n"));
1529 assert!(!source_mentions_require("local x = prerequire\n"));
1530 assert!(!source_mentions_require("local record c\nend\nreturn c\n"));
1531 assert!(!source_mentions_require(""));
1532 }
1533
1534 #[test]
1535 fn scratch_roots_are_recognised() {
1536 assert_eq!(
1537 scratch_root(Path::new("/w/target/package/host-0.1.0")),
1538 Some("under a `target` directory")
1539 );
1540 assert_eq!(scratch_root(Path::new("/w/host")), None);
1541 let cargo_home = std::env::var_os("CARGO_HOME")
1542 .map(PathBuf::from)
1543 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cargo")))
1544 .expect("CARGO_HOME or HOME");
1545 assert_eq!(
1546 scratch_root(&cargo_home.join("registry/src/index/htl-0.3.0")),
1547 Some("a registry checkout")
1548 );
1549 }
1550}