Skip to main content

skif_js_syntax/
cache.rs

1//! A cache of parse results keyed by everything a parse depends on (ADR 0029).
2//!
3//! A [`ModuleInfo`] is a pure function of the file name, the text and the [`ParseOptions`], so a key over those
4//! inputs can never return a stale result: a changed file has a new key. The cache itself is supplied by the host
5//! through [`ModuleCache`]; this crate only owns the key, the stored value, an in-memory implementation and the
6//! [`ParseCoalescer`] that makes the threads of one run parse each key once (ADR 0029).
7
8use std::collections::BTreeMap;
9
10use skif_core::HashMap;
11use std::hash::Hasher as _;
12use std::path::Path;
13use std::sync::atomic::{AtomicUsize, Ordering};
14use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError};
15use std::thread::{self, ThreadId};
16
17use serde::{Deserialize, Serialize};
18use siphasher::sip128::{Hasher128 as _, SipHasher13};
19
20use crate::error::SyntaxError;
21use crate::model::{ModuleInfo, ParseOptions};
22use crate::style::{is_stylesheet, parse_stylesheet};
23
24/// The version of the parse output. Raise it whenever [`ModuleInfo`] or what the parsers put in it changes, so a
25/// cache written by an older build at the same crate version is not trusted; the test
26/// `the_cache_schema_matches_the_parse_output` fails until [`CACHE_SCHEMA_DIGEST`] is updated with it.
27pub const CACHE_SCHEMA: u32 = 5;
28
29/// The digest of the serialized parse results of a fixed probe corpus at [`CACHE_SCHEMA`]. It is a test's guard,
30/// not part of any key: when the parse output changes, the test reports the new digest and both constants move.
31pub const CACHE_SCHEMA_DIGEST: u128 = 0x5590_3ede_e8ea_3a95_93dd_202f_6d05_dbd5;
32
33/// The version a persisted cache records, `<crate version>+<CACHE_SCHEMA>`: a store written under another version
34/// is discarded as a whole.
35#[must_use]
36pub fn cache_version() -> String {
37    format!("{}+{CACHE_SCHEMA}", env!("CARGO_PKG_VERSION"))
38}
39
40/// The key of one parse result: a 128-bit `SipHash-1-3` of the schema, the crate version, the parser, the parse
41/// options, the file name and the text. The directory is not part of it, so identical files share an entry and a
42/// moved checkout keeps its cache.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
44pub struct ModuleKey(u128);
45
46impl ModuleKey {
47    /// The key for parsing `source`, the text of `path`, with `options`.
48    #[must_use]
49    pub fn new(path: &Path, source: &str, options: &ParseOptions) -> Self {
50        // Destructured without `..`, so a new parse option does not compile until it is part of the key.
51        let ParseOptions {
52            in_file_references,
53            member_accesses,
54        } = *options;
55        let mut hasher = SipHasher13::new_with_keys(0, 0);
56        hasher.write(&CACHE_SCHEMA.to_le_bytes());
57        write_field(&mut hasher, env!("CARGO_PKG_VERSION").as_bytes());
58        hasher.write_u8(u8::from(is_stylesheet(path)));
59        hasher.write_u8(u8::from(in_file_references));
60        hasher.write_u8(u8::from(member_accesses));
61        write_field(
62            &mut hasher,
63            path.file_name().map_or_default(std::ffi::OsStr::as_encoded_bytes),
64        );
65        write_field(&mut hasher, source.as_bytes());
66        Self(hasher.finish128().as_u128())
67    }
68
69    /// The key as 16 little-endian bytes, for a host that persists it.
70    #[must_use]
71    pub const fn to_bytes(self) -> [u8; 16] {
72        self.0.to_le_bytes()
73    }
74
75    /// A key from the bytes [`ModuleKey::to_bytes`] gave.
76    #[must_use]
77    pub const fn from_bytes(bytes: [u8; 16]) -> Self {
78        Self(u128::from_le_bytes(bytes))
79    }
80}
81
82/// Writes a variable-length field with its length first, so two different splits of the same bytes never hash alike.
83fn write_field(hasher: &mut SipHasher13, bytes: &[u8]) {
84    hasher.write(&(bytes.len() as u64).to_le_bytes());
85    hasher.write(bytes);
86}
87
88/// One cached parse result. Its fields are private: a host stores and serializes it without depending on the shape
89/// of [`ModuleInfo`].
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(transparent)]
92pub struct CachedModule(ModuleInfo);
93
94/// A store of parse results the host owns and gives the analysis.
95///
96/// Both methods are called from the parallel parse step. A failed lookup, or a stored value the implementation
97/// cannot decode, is a miss (`None`), never an error.
98pub trait ModuleCache: Send + Sync {
99    /// The result stored under `key`, if any.
100    fn get(&self, key: ModuleKey) -> Option<CachedModule>;
101    /// Stores `module` under `key`.
102    fn put(&self, key: ModuleKey, module: CachedModule);
103}
104
105/// An in-process [`ModuleCache`] for long-lived hosts (watch mode, the language server, the MCP server).
106///
107/// Every entry carries a mark set when it is read or written; [`MemoryModuleCache::prune_unused`] drops the entries
108/// without a mark and clears the marks, so a host that prunes after each run keeps only what its last run used.
109#[derive(Debug, Default)]
110pub struct MemoryModuleCache {
111    entries: Mutex<BTreeMap<ModuleKey, (Arc<CachedModule>, bool)>>,
112}
113
114impl MemoryModuleCache {
115    /// An empty cache.
116    #[must_use]
117    pub fn new() -> Self {
118        Self::default()
119    }
120
121    /// The number of entries.
122    #[must_use]
123    pub fn len(&self) -> usize {
124        self.entries.lock().unwrap_or_else(PoisonError::into_inner).len()
125    }
126
127    /// Whether the cache holds no entry.
128    #[must_use]
129    pub fn is_empty(&self) -> bool {
130        self.len() == 0
131    }
132
133    /// Drops every entry not read or written since the last prune, and clears the marks of the others.
134    pub fn prune_unused(&self) {
135        let mut entries = self.entries.lock().unwrap_or_else(PoisonError::into_inner);
136        entries.retain(|_, (_, used)| std::mem::replace(used, false));
137    }
138}
139
140impl ModuleCache for MemoryModuleCache {
141    fn get(&self, key: ModuleKey) -> Option<CachedModule> {
142        let found = {
143            let mut entries = self.entries.lock().unwrap_or_else(PoisonError::into_inner);
144            let (module, used) = entries.get_mut(&key)?;
145            *used = true;
146            Arc::clone(module)
147        };
148        // Cloned outside the lock, so parallel lookups do not wait on each other's copies.
149        Some((*found).clone())
150    }
151
152    fn put(&self, key: ModuleKey, module: CachedModule) {
153        self.entries
154            .lock()
155            .unwrap_or_else(PoisonError::into_inner)
156            .insert(key, (Arc::new(module), true));
157    }
158}
159
160/// Parses each key once while several threads ask for it: a lookup of a key another thread is parsing waits for
161/// that parse and is given its outcome instead of parsing the file again (ADR 0029).
162///
163/// It is not a [`ModuleCache`] and holds no result: it sits beside the cache a run parses through, and
164/// [`ParseCoalescer::parse_cached`] takes that cache, so coalescing does not depend on how the cache is wrapped. It
165/// holds one slot per key being parsed, with its own lock and condition variable, so a wait for one key neither
166/// blocks nor wakes the lookups of another. The thread that claims a key parses it, stores the result in the cache
167/// and hands the result, or the parse error, to the threads that waited; a parse that panics hands them nothing, and
168/// one of them parses the key again.
169///
170/// Deadlock freedom rests on one invariant: a parse never waits for another lookup through the coalescer. The
171/// parsers run on the calling thread, use no thread pool and do not read the cache. A lookup of a key the calling
172/// thread has claimed itself parses inline instead of waiting for itself.
173#[derive(Default)]
174pub struct ParseCoalescer {
175    slots: Mutex<HashMap<ModuleKey, Arc<Slot>>>,
176}
177
178/// The parse of one key in flight.
179struct Slot {
180    /// The thread parsing the key.
181    claimer: ThreadId,
182    /// The threads waiting for the outcome. It changes only under the lock of [`ParseCoalescer::slots`], so it is
183    /// final once the claimer has taken the slot out of the map.
184    waiters: AtomicUsize,
185    outcome: Mutex<Option<Outcome>>,
186    ready: Condvar,
187}
188
189/// What the claimer of a key hands the threads that waited for it.
190#[derive(Clone)]
191enum Outcome {
192    Parsed(Arc<CachedModule>),
193    Failed(SyntaxError),
194    /// The parse panicked: the waiters look the key up again.
195    Abandoned,
196}
197
198/// The answer of [`ParseCoalescer::join_or_claim`].
199enum Turn<'a> {
200    /// Another thread parses the key; wait for this slot.
201    Wait(Arc<Slot>),
202    /// The calling thread parses the key.
203    Parse(Claim<'a>),
204    /// The calling thread is already parsing the key further up its stack.
205    Inline,
206}
207
208impl ParseCoalescer {
209    /// A coalescer with no parse in flight.
210    #[must_use]
211    pub fn new() -> Self {
212        Self::default()
213    }
214
215    /// As [`parse_cached`] through `cache`, with a lookup of a key another thread is parsing waiting for that parse.
216    ///
217    /// # Errors
218    /// As [`crate::parse_module`], also when the error comes from the parse another thread ran for the same key.
219    pub fn parse_cached(
220        &self,
221        cache: &dyn ModuleCache,
222        path: &Path,
223        source: &str,
224        options: &ParseOptions,
225    ) -> Result<ModuleInfo, SyntaxError> {
226        let key = ModuleKey::new(path, source, options);
227        self.get_or_parse(cache, key, &mut || {
228            parse_uncached(path, source, *options).map(CachedModule)
229        })
230        .map(|CachedModule(info)| info)
231    }
232
233    /// The result stored under `key` in `cache`, or the outcome of the one parse of `key` that runs while several
234    /// threads ask for it. A fresh result is stored before any waiter is woken.
235    fn get_or_parse(
236        &self,
237        cache: &dyn ModuleCache,
238        key: ModuleKey,
239        parse: &mut dyn FnMut() -> Result<CachedModule, SyntaxError>,
240    ) -> Result<CachedModule, SyntaxError> {
241        loop {
242            if let Some(found) = cache.get(key) {
243                return Ok(found);
244            }
245            let slot = match self.join_or_claim(key) {
246                Turn::Wait(slot) => slot,
247                Turn::Parse(mut claim) => {
248                    // Looked up again with the key claimed: a parse that ended since the lookup above stored its
249                    // result before it released the key.
250                    let result = match cache.get(key) {
251                        Some(found) => Ok(found),
252                        None => parse().inspect(|module| cache.put(key, module.clone())),
253                    };
254                    claim.release(|| match &result {
255                        Ok(module) => Outcome::Parsed(Arc::new(module.clone())),
256                        Err(error) => Outcome::Failed(error.clone()),
257                    });
258                    return result;
259                }
260                Turn::Inline => return parse(),
261            };
262            match slot.wait() {
263                Outcome::Parsed(module) => return Ok(Arc::unwrap_or_clone(module)),
264                Outcome::Failed(error) => return Err(error),
265                Outcome::Abandoned => {}
266            }
267        }
268    }
269
270    fn slots(&self) -> MutexGuard<'_, HashMap<ModuleKey, Arc<Slot>>> {
271        self.slots.lock().unwrap_or_else(PoisonError::into_inner)
272    }
273
274    /// Joins the parse of `key` in flight, or claims `key` for the calling thread.
275    fn join_or_claim(&self, key: ModuleKey) -> Turn<'_> {
276        let current = thread::current().id();
277        let mut slots = self.slots();
278        if let Some(slot) = slots.get(&key) {
279            if slot.claimer == current {
280                return Turn::Inline;
281            }
282            slot.waiters.fetch_add(1, Ordering::Relaxed);
283            return Turn::Wait(Arc::clone(slot));
284        }
285        let slot = Arc::new(Slot {
286            claimer: current,
287            waiters: AtomicUsize::new(0),
288            outcome: Mutex::new(None),
289            ready: Condvar::new(),
290        });
291        slots.insert(key, Arc::clone(&slot));
292        Turn::Parse(Claim {
293            coalescer: self,
294            key,
295            slot,
296            released: false,
297        })
298    }
299}
300
301impl std::fmt::Debug for ParseCoalescer {
302    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
303        f.debug_struct("ParseCoalescer")
304            .field("parsing", &self.slots().len())
305            .finish_non_exhaustive()
306    }
307}
308
309impl Slot {
310    /// Blocks until the claimer hands over the outcome.
311    fn wait(&self) -> Outcome {
312        let mut outcome = self.outcome.lock().unwrap_or_else(PoisonError::into_inner);
313        loop {
314            if let Some(outcome) = outcome.as_ref() {
315                return outcome.clone();
316            }
317            outcome = self.ready.wait(outcome).unwrap_or_else(PoisonError::into_inner);
318        }
319    }
320}
321
322/// A key claimed by [`ParseCoalescer::join_or_claim`]. Dropping it unreleased, as a panicking parse does, releases
323/// it with [`Outcome::Abandoned`].
324struct Claim<'a> {
325    coalescer: &'a ParseCoalescer,
326    key: ModuleKey,
327    slot: Arc<Slot>,
328    released: bool,
329}
330
331impl Claim<'_> {
332    /// Takes the slot out of the map, so later lookups go to the cache, and hands the waiters `outcome`, which is
333    /// built only when a thread waits.
334    fn release(&mut self, outcome: impl FnOnce() -> Outcome) {
335        self.released = true;
336        self.coalescer.slots().remove(&self.key);
337        if self.slot.waiters.load(Ordering::Relaxed) == 0 {
338            return;
339        }
340        *self.slot.outcome.lock().unwrap_or_else(PoisonError::into_inner) = Some(outcome());
341        self.slot.ready.notify_all();
342    }
343}
344
345impl Drop for Claim<'_> {
346    fn drop(&mut self) {
347        if !self.released {
348            self.release(|| Outcome::Abandoned);
349        }
350    }
351}
352
353/// Parses `source`, the text of `path`, with `options`, through `cache`: a stored result is returned as it is, and
354/// a fresh parse is stored before it is returned. Stylesheets go to [`parse_stylesheet`] and everything else to
355/// [`crate::parse_module`]. Without a cache this is a plain parse.
356///
357/// # Errors
358/// As [`crate::parse_module`]. An unsupported file is never stored.
359pub fn parse_cached(
360    cache: Option<&dyn ModuleCache>,
361    path: &Path,
362    source: &str,
363    options: &ParseOptions,
364) -> Result<ModuleInfo, SyntaxError> {
365    let Some(cache) = cache else {
366        return parse_uncached(path, source, *options);
367    };
368    let key = ModuleKey::new(path, source, options);
369    if let Some(CachedModule(info)) = cache.get(key) {
370        return Ok(info);
371    }
372    let info = parse_uncached(path, source, *options)?;
373    cache.put(key, CachedModule(info.clone()));
374    Ok(info)
375}
376
377/// Parses `source`, the text of `path`, with the parser its name selects.
378fn parse_uncached(path: &Path, source: &str, options: ParseOptions) -> Result<ModuleInfo, SyntaxError> {
379    if is_stylesheet(path) {
380        Ok(parse_stylesheet(path, source))
381    } else {
382        crate::parse_module(path, source, &options)
383    }
384}
385
386const _: fn() = || {
387    fn assert_send_sync<T: Send + Sync>() {}
388    assert_send_sync::<ModuleKey>();
389    assert_send_sync::<CachedModule>();
390    assert_send_sync::<MemoryModuleCache>();
391    assert_send_sync::<ParseCoalescer>();
392};
393
394#[cfg(test)]
395#[allow(clippy::unwrap_used, clippy::expect_used)]
396mod tests {
397    use std::path::PathBuf;
398    use std::sync::atomic::{AtomicUsize, Ordering};
399
400    use super::*;
401
402    /// One source per language the parsers know and one of test-runner mocks, plus the bench corpus: the probe whose
403    /// parse output the schema digest pins.
404    fn probe_corpus() -> Vec<(PathBuf, String)> {
405        let inline: [(&str, &str); 16] = [
406            (
407                "a.ts",
408                "import { a, type B } from './b';\nimport * as ns from 'ns';\nexport const c = a + ns.x;\n\
409                 export enum E { A, B }\nexport function f(): B { return E.A as never; }\n\
410                 export default class K {}\n",
411            ),
412            (
413                "b.tsx",
414                "'use client';\nimport React from 'react';\nexport const App = () => <div>{React.version}</div>;\n",
415            ),
416            (
417                "c.js",
418                "const x = require('x');\nmodule.exports = { x };\nimport('./lazy');\nimport(`./dir/${x}`);\n",
419            ),
420            (
421                "d.d.ts",
422                "declare module 'virtual:thing' {\n  export const v: number;\n}\n",
423            ),
424            (
425                "e.mjs",
426                "export * from './e2';\nexport { default as g } from './g';\nimport.meta.glob('./*.js');\n",
427            ),
428            (
429                "f.vue",
430                "<script setup lang=\"ts\">\nimport Child from './Child.vue';\nimport { ref } from 'vue';\n\
431                 const n = ref(0);\n</script>\n<template><Child :n=\"n\" /><app-card v-for=\"item in items\" />\
432                 <component is=\"AppBadge\" /></template>\n<style lang=\"scss\">@use './theme';</style>\n",
433            ),
434            (
435                "g.svelte",
436                "<script>\n  import Button from './Button.svelte';\n  export let label;\n</script>\n\
437                 <Button>{label}</Button>\n",
438            ),
439            (
440                "h.astro",
441                "---\nimport Layout from '../layouts/Layout.astro';\nconst title = 'x';\n---\n\
442                 <Layout title={title}><p>hi</p></Layout>\n",
443            ),
444            (
445                "i.mdx",
446                "import { Chart } from './chart';\nexport const meta = { title: 'x' };\n\n# Title\n\n<Chart />\n",
447            ),
448            (
449                "j.tsrx",
450                "import { thing } from './thing';\nexport component App() { <div>{thing}</div> }\n",
451            ),
452            (
453                "k.css",
454                "@import './base.css';\n.a { composes: b from './other.css'; }\n",
455            ),
456            (
457                "l.scss",
458                "@use 'sass:math';\n@use './vars' as v;\n@forward './mixins';\n.a { b: v.$c; }\n",
459            ),
460            (
461                "n.marko",
462                "import format from './format';\nimport Card from '<card>';\n<div>${format(input.value)}</div>\n\
463                 style.less {\n  @import './theme.less';\n}\n",
464            ),
465            (
466                "m.test.ts",
467                "vi.mock('./x');\nvi.mock(import('./y'), () => ({}));\njest.requireActual('./z');\n\
468                 jest.mock('v', () => ({}), { virtual: true });\n",
469            ),
470            (
471                "n.ts",
472                "import * as ns from './ns';\nimport * as chars from './chars';\n\
473                 export const read = ns.E.A + chars.a;\nexport function scan(chars: string) { return chars.length; }\n\
474                 export async function lazy() { const ns = await import('./ns'); return ns.b; }\n\
475                 export interface Options { a: string }\nexport const Service = make();\n\
476                 export type Service = { run(options: Options): void };\n",
477            ),
478            (
479                "o.mjs",
480                "import { execSync, spawn } from 'node:child_process';\nimport path from 'node:path';\n\
481                 execSync('npm run build', { cwd: '/tmp/app' });\nspawn('tsc', ['-b'], { cwd: process.cwd() });\n\
482                 execSync('pnpm exec rspack', { cwd: path.join(import.meta.dirname, '../sub') });\n",
483            ),
484        ];
485        let mut corpus: Vec<(PathBuf, String)> = inline
486            .iter()
487            .map(|(name, source)| (PathBuf::from(name), (*source).to_owned()))
488            .collect();
489        let bench = Path::new(env!("CARGO_MANIFEST_DIR")).join("benches").join("corpus");
490        let mut files: Vec<PathBuf> = std::fs::read_dir(&bench)
491            .unwrap()
492            .map(|entry| entry.unwrap().path())
493            .collect();
494        files.sort();
495        assert!(!files.is_empty(), "the bench corpus is empty");
496        for file in files {
497            let source = std::fs::read_to_string(&file).unwrap();
498            corpus.push((PathBuf::from(file.file_name().unwrap()), source));
499        }
500        // A file the parser cannot recover from is cached too.
501        corpus.push((PathBuf::from("broken.ts"), "export const = ;;; {{{".to_owned()));
502        corpus
503    }
504
505    fn key(name: &str, source: &str, options: ParseOptions) -> ModuleKey {
506        ModuleKey::new(Path::new(name), source, &options)
507    }
508
509    #[test]
510    fn the_key_changes_with_each_input() {
511        let options = ParseOptions::default();
512        let base = key("a.ts", "export const a = 1;", options);
513        let others = [
514            key("a.ts", "export const a = 2;", options),
515            key("b.ts", "export const a = 1;", options),
516            key("a.d.ts", "export const a = 1;", options),
517            key("a.tsx", "export const a = 1;", options),
518            key(
519                "a.ts",
520                "export const a = 1;",
521                ParseOptions {
522                    in_file_references: false,
523                    ..options
524                },
525            ),
526            key(
527                "a.ts",
528                "export const a = 1;",
529                ParseOptions {
530                    member_accesses: false,
531                    ..options
532                },
533            ),
534        ];
535        for other in others {
536            assert_ne!(base, other);
537        }
538        // The parser: the same file name stem as a stylesheet and as a script.
539        assert_ne!(key("a.css", "", options), key("a.js", "", options));
540    }
541
542    #[test]
543    fn the_key_ignores_the_directory() {
544        let options = ParseOptions::default();
545        assert_eq!(
546            ModuleKey::new(Path::new("/one/src/a.ts"), "x", &options),
547            ModuleKey::new(Path::new("/two/lib/a.ts"), "x", &options)
548        );
549    }
550
551    #[test]
552    fn the_key_round_trips_through_bytes() {
553        let key = key("a.ts", "x", ParseOptions::default());
554        assert_eq!(ModuleKey::from_bytes(key.to_bytes()), key);
555    }
556
557    #[test]
558    fn parse_results_round_trip_through_serde() {
559        for (path, source) in probe_corpus() {
560            let info = parse_cached(None, &path, &source, &ParseOptions::default()).unwrap();
561            let cached = CachedModule(info);
562            let json = serde_json::to_string(&cached).unwrap();
563            let back: CachedModule = serde_json::from_str(&json).unwrap();
564            assert_eq!(back, cached, "{}", path.display());
565        }
566    }
567
568    #[test]
569    fn the_cache_schema_matches_the_parse_output() {
570        let mut hasher = SipHasher13::new_with_keys(0, 0);
571        for (path, source) in probe_corpus() {
572            for options in [
573                ParseOptions::default(),
574                ParseOptions {
575                    in_file_references: false,
576                    member_accesses: false,
577                },
578            ] {
579                let info = parse_cached(None, &path, &source, &options).unwrap();
580                write_field(&mut hasher, path.as_os_str().as_encoded_bytes());
581                write_field(&mut hasher, &serde_json::to_vec(&info).unwrap());
582            }
583        }
584        let digest = hasher.finish128().as_u128();
585        assert_eq!(
586            digest, CACHE_SCHEMA_DIGEST,
587            "the parse output changed: raise CACHE_SCHEMA (now {CACHE_SCHEMA}) and set CACHE_SCHEMA_DIGEST to \
588             {digest:#034x}"
589        );
590    }
591
592    /// Counts the lookups that hit and the stores of a [`MemoryModuleCache`].
593    #[derive(Default)]
594    struct Counting {
595        inner: MemoryModuleCache,
596        hits: AtomicUsize,
597        puts: AtomicUsize,
598    }
599
600    impl ModuleCache for Counting {
601        fn get(&self, key: ModuleKey) -> Option<CachedModule> {
602            let found = self.inner.get(key);
603            if found.is_some() {
604                self.hits.fetch_add(1, Ordering::Relaxed);
605            }
606            found
607        }
608
609        fn put(&self, key: ModuleKey, module: CachedModule) {
610            self.puts.fetch_add(1, Ordering::Relaxed);
611            self.inner.put(key, module);
612        }
613    }
614
615    #[test]
616    fn parse_cached_stores_on_a_miss_and_returns_the_stored_result_on_a_hit() {
617        let cache = Counting::default();
618        let options = ParseOptions::default();
619        let path = Path::new("a.ts");
620        let first = parse_cached(Some(&cache), path, "import x from 'x'; export { x };", &options).unwrap();
621        let second = parse_cached(Some(&cache), path, "import x from 'x'; export { x };", &options).unwrap();
622        assert_eq!(first, second);
623        assert_eq!(cache.puts.load(Ordering::Relaxed), 1);
624        assert_eq!(cache.hits.load(Ordering::Relaxed), 1);
625        // An unsupported file is an error and is not stored.
626        assert!(parse_cached(Some(&cache), Path::new("a.txt"), "", &options).is_err());
627        assert_eq!(cache.puts.load(Ordering::Relaxed), 1);
628    }
629
630    #[test]
631    fn prune_unused_keeps_only_the_entries_used_since_the_last_prune() {
632        let cache = MemoryModuleCache::new();
633        let options = ParseOptions::default();
634        let (a, b) = (key("a.ts", "a", options), key("b.ts", "b", options));
635        cache.put(a, CachedModule(ModuleInfo::default()));
636        cache.put(b, CachedModule(ModuleInfo::default()));
637        assert_eq!(cache.len(), 2);
638        // Both were written since the last prune.
639        cache.prune_unused();
640        assert_eq!(cache.len(), 2);
641        assert!(cache.get(a).is_some());
642        cache.prune_unused();
643        assert_eq!(cache.len(), 1);
644        assert!(cache.get(a).is_some());
645        assert!(cache.get(b).is_none());
646        cache.prune_unused();
647        cache.prune_unused();
648        assert!(cache.is_empty());
649    }
650
651    #[test]
652    fn parse_options_that_differ_do_not_share_an_entry() {
653        let cache = Counting::default();
654        let path = Path::new("a.ts");
655        let source = "const local = 1;\nexport const a = local;\nexport const b = a;\n";
656        let full = ParseOptions::default();
657        let bare = ParseOptions {
658            in_file_references: false,
659            member_accesses: false,
660        };
661        let with_references = parse_cached(Some(&cache), path, source, &full).unwrap();
662        let without = parse_cached(Some(&cache), path, source, &bare).unwrap();
663        assert_eq!(cache.puts.load(Ordering::Relaxed), 2);
664        assert_eq!(cache.hits.load(Ordering::Relaxed), 0);
665        assert_eq!(cache.inner.len(), 2);
666        // Each lookup is served what its own options parse to, from the entry of those options.
667        assert_eq!(
668            parse_cached(Some(&cache), path, source, &full).unwrap(),
669            with_references
670        );
671        assert_eq!(parse_cached(Some(&cache), path, source, &bare).unwrap(), without);
672        assert_eq!(with_references, parse_cached(None, path, source, &full).unwrap());
673        assert_eq!(without, parse_cached(None, path, source, &bare).unwrap());
674        assert_eq!(cache.hits.load(Ordering::Relaxed), 2);
675    }
676
677    type Parse = Result<CachedModule, SyntaxError>;
678
679    /// A parse result of its own, so a lookup that returns it was handed this parse.
680    fn module(source: &str) -> CachedModule {
681        CachedModule(crate::parse_module(Path::new("a.ts"), source, &ParseOptions::default()).unwrap())
682    }
683
684    fn unsupported() -> SyntaxError {
685        SyntaxError::Unsupported {
686            extension: "txt".to_owned(),
687        }
688    }
689
690    /// The threads registered as waiting for the parse of `key` in flight.
691    fn waiting(coalescer: &ParseCoalescer, key: ModuleKey) -> usize {
692        coalescer
693            .slots()
694            .get(&key)
695            .map_or(0, |slot| slot.waiters.load(Ordering::SeqCst))
696    }
697
698    /// Returns once `count` threads wait for the parse of `key`, from when each of them can only be handed the
699    /// claimer's outcome, or once `gave_up` holds. The deadline only bounds a failing test.
700    fn until_waiting(coalescer: &ParseCoalescer, key: ModuleKey, count: usize, gave_up: impl Fn() -> bool) {
701        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
702        while waiting(coalescer, key) < count && !gave_up() {
703            assert!(
704                std::time::Instant::now() < deadline,
705                "{count} lookups never waited for the parse"
706            );
707            std::thread::yield_now();
708        }
709    }
710
711    /// The parse of a lookup that must be handed another thread's outcome.
712    fn must_not_parse() -> Parse {
713        panic!("a waiting lookup parsed the key itself")
714    }
715
716    /// Lets one thread claim `key` and start parsing, then a second look the key up with `waiter_parse`, and ends
717    /// the first parse with `finish` only once the second is registered as waiting. Returns the outcome of the
718    /// first, an `Err` when its parse panicked, and of the second.
719    fn claimer_and_waiter(
720        coalescer: &ParseCoalescer,
721        cache: &dyn ModuleCache,
722        key: ModuleKey,
723        finish: impl FnOnce() -> Parse + Send,
724        mut waiter_parse: impl FnMut() -> Parse + Send,
725    ) -> (std::thread::Result<Parse>, Parse) {
726        let (started, has_started) = std::sync::mpsc::channel();
727        let (release, released) = std::sync::mpsc::channel::<()>();
728        std::thread::scope(|scope| {
729            let first = scope.spawn(move || {
730                let mut finish = Some(finish);
731                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
732                    coalescer.get_or_parse(cache, key, &mut || {
733                        started.send(()).unwrap();
734                        released.recv().unwrap();
735                        finish.take().unwrap()()
736                    })
737                }))
738            });
739            has_started.recv().unwrap();
740            let second = scope.spawn(move || coalescer.get_or_parse(cache, key, &mut waiter_parse));
741            until_waiting(coalescer, key, 1, || second.is_finished());
742            release.send(()).unwrap();
743            (first.join().unwrap(), second.join().unwrap())
744        })
745    }
746
747    #[test]
748    fn a_lookup_of_a_key_being_parsed_is_handed_that_parse() {
749        let store = Counting::default();
750        let coalescer = ParseCoalescer::new();
751        let key = key("a.ts", "a", ParseOptions::default());
752        let parsed = module("export const parsed = 1;");
753        let (first, second) = claimer_and_waiter(&coalescer, &store, key, || Ok(parsed.clone()), must_not_parse);
754        assert_eq!(first.unwrap(), Ok(parsed.clone()));
755        assert_eq!(second, Ok(parsed.clone()));
756        // One parse, stored once; the waiter was handed it, not served by the store.
757        assert_eq!(store.puts.load(Ordering::Relaxed), 1);
758        assert_eq!(store.hits.load(Ordering::Relaxed), 0);
759        assert_eq!(store.inner.get(key), Some(parsed));
760        assert_eq!(waiting(&coalescer, key), 0);
761        assert!(coalescer.slots().is_empty());
762    }
763
764    #[test]
765    fn a_failed_parse_is_handed_to_its_waiters_and_stores_nothing() {
766        let store = Counting::default();
767        let coalescer = ParseCoalescer::new();
768        let key = key("a.ts", "a", ParseOptions::default());
769        let (first, second) = claimer_and_waiter(&coalescer, &store, key, || Err(unsupported()), must_not_parse);
770        assert_eq!(first.unwrap(), Err(unsupported()));
771        assert_eq!(second, Err(unsupported()));
772        assert_eq!(store.puts.load(Ordering::Relaxed), 0);
773        assert!(store.inner.is_empty());
774        // The key was released: the next lookup parses it.
775        let mut parses = 0;
776        let found = coalescer.get_or_parse(&store, key, &mut || {
777            parses += 1;
778            Ok(module("export const later = 1;"))
779        });
780        assert_eq!(parses, 1);
781        assert_eq!(found, Ok(module("export const later = 1;")));
782        assert_eq!(store.inner.len(), 1);
783    }
784
785    #[test]
786    fn a_panicking_parse_leaves_one_waiter_to_parse_the_key_again() {
787        let store = Counting::default();
788        let coalescer = ParseCoalescer::new();
789        let key = key("a.ts", "a", ParseOptions::default());
790        let waiter_parses = AtomicUsize::new(0);
791        let (first, second) = claimer_and_waiter(
792            &coalescer,
793            &store,
794            key,
795            || -> Parse { panic!("the parser failed") },
796            || {
797                waiter_parses.fetch_add(1, Ordering::SeqCst);
798                Ok(module("export const retried = 1;"))
799            },
800        );
801        assert!(first.is_err());
802        // The panic handed the waiter nothing; it parsed the key itself, once, and stored that result.
803        assert_eq!(second, Ok(module("export const retried = 1;")));
804        assert_eq!(waiter_parses.load(Ordering::SeqCst), 1);
805        assert_eq!(store.puts.load(Ordering::Relaxed), 1);
806        assert_eq!(store.inner.len(), 1);
807        assert_eq!(store.inner.get(key), Some(module("export const retried = 1;")));
808        assert!(coalescer.slots().is_empty());
809    }
810
811    #[test]
812    fn many_threads_asking_for_one_key_parse_it_once() {
813        let store = MemoryModuleCache::new();
814        let coalescer = ParseCoalescer::new();
815        let key = key("a.ts", "a", ParseOptions::default());
816        let parses = AtomicUsize::new(0);
817        let barrier = std::sync::Barrier::new(8);
818        let found: Vec<Parse> = std::thread::scope(|scope| {
819            let mut lookups = Vec::new();
820            for _ in 0..8 {
821                lookups.push(scope.spawn(|| {
822                    barrier.wait();
823                    coalescer.get_or_parse(&store, key, &mut || {
824                        parses.fetch_add(1, Ordering::SeqCst);
825                        // The claimer holds the key until the seven others wait for it.
826                        until_waiting(&coalescer, key, 7, || false);
827                        Ok(module("export const once = 1;"))
828                    })
829                }));
830            }
831            lookups.into_iter().map(|lookup| lookup.join().unwrap()).collect()
832        });
833        assert_eq!(parses.load(Ordering::SeqCst), 1);
834        assert!(found.iter().all(|found| *found == Ok(module("export const once = 1;"))));
835        assert_eq!(store.len(), 1);
836    }
837
838    #[test]
839    fn a_lookup_of_a_key_its_own_thread_is_parsing_parses_inline() {
840        let store = MemoryModuleCache::new();
841        let coalescer = ParseCoalescer::new();
842        let key = key("a.ts", "a", ParseOptions::default());
843        let found = coalescer.get_or_parse(&store, key, &mut || {
844            coalescer.get_or_parse(&store, key, &mut || Ok(module("export const inner = 1;")))
845        });
846        assert_eq!(found, Ok(module("export const inner = 1;")));
847        assert_eq!(store.get(key), Some(module("export const inner = 1;")));
848        assert!(coalescer.slots().is_empty());
849    }
850
851    #[test]
852    fn parse_cached_through_a_coalescer_stores_once_and_reports_parse_errors() {
853        let store = Counting::default();
854        let coalescer = ParseCoalescer::new();
855        let options = ParseOptions::default();
856        let path = Path::new("a.ts");
857        let first = coalescer
858            .parse_cached(&store, path, "export const a = 1;", &options)
859            .unwrap();
860        let second = coalescer
861            .parse_cached(&store, path, "export const a = 1;", &options)
862            .unwrap();
863        assert_eq!(first, second);
864        assert_eq!(
865            first,
866            parse_cached(None, path, "export const a = 1;", &options).unwrap()
867        );
868        assert_eq!(store.puts.load(Ordering::Relaxed), 1);
869        assert_eq!(store.hits.load(Ordering::Relaxed), 1);
870        assert_eq!(
871            coalescer.parse_cached(&store, Path::new("a.txt"), "", &options),
872            Err(unsupported())
873        );
874        assert_eq!(store.puts.load(Ordering::Relaxed), 1);
875    }
876}