use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock, Mutex};
use bynk_syntax::ast::SourceUnit;
use bynk_syntax::error::CompileError;
use bynk_syntax::span::FileId;
use bynk_syntax::{lexer, parser};
type StrictParseResult =
Result<(Arc<Vec<SourceUnit>>, Arc<Vec<CompileError>>), Arc<Vec<CompileError>>>;
struct CachedParse {
content: Arc<str>,
result: StrictParseResult,
recovered: Option<Arc<parser::Recovered>>,
}
#[derive(Default)]
struct ParseCacheState {
next_file_id: u32,
file_ids: HashMap<PathBuf, FileId>,
next_expr_id: u32,
entries: HashMap<FileId, CachedParse>,
}
const PARSE_CACHE_CAP: usize = 4096;
const FIRSTPARTY_ID_BASE: u32 = 1_000_000_000;
static CACHE: LazyLock<Mutex<ParseCacheState>> =
LazyLock::new(|| Mutex::new(ParseCacheState::default()));
pub fn file_id_for(path: &Path) -> FileId {
let mut state = CACHE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
file_id_for_locked(&mut state, path)
}
fn file_id_for_locked(state: &mut ParseCacheState, path: &Path) -> FileId {
if let Some(&id) = state.file_ids.get(path) {
return id;
}
let id = FileId(state.next_file_id);
state.next_file_id += 1;
state.file_ids.insert(path.to_path_buf(), id);
id
}
pub fn cached_parse(path: &Path, content: &str) -> (FileId, StrictParseResult) {
let mut state = CACHE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
cached_parse_in(&mut state, path, content, PARSE_CACHE_CAP)
}
fn cached_parse_in(
state: &mut ParseCacheState,
path: &Path,
content: &str,
cap: usize,
) -> (FileId, StrictParseResult) {
let id = file_id_for_locked(state, path);
if let Some(entry) = state.entries.get(&id)
&& &*entry.content == content
{
return (id, entry.result.clone());
}
let mut recovered = None;
let result = match lexer::tokenize_in(content, id) {
Ok(tokens) => {
match parser::parse_units_with_warnings_from(&tokens, content, &mut state.next_expr_id)
{
Ok((units, warnings)) => Ok((Arc::new(units), Arc::new(warnings))),
Err(errors) => {
recovered = Some(Arc::new(parser::parse_units_recovering_from(
&tokens,
content,
&mut state.next_expr_id,
)));
Err(Arc::new(errors))
}
}
}
Err(e) => Err(Arc::new(vec![e])),
};
debug_assert!(
state.next_expr_id < FIRSTPARTY_ID_BASE,
"durable ExprId counter ({}) reached the first-party reservation ({FIRSTPARTY_ID_BASE}) \
— see this module's own doc comment",
state.next_expr_id,
);
if state.entries.len() >= cap && !state.entries.contains_key(&id) {
state.entries.clear();
}
state.entries.insert(
id,
CachedParse {
content: Arc::from(content),
result: result.clone(),
recovered,
},
);
(id, result)
}
pub fn cached_recovery(path: &Path, content: &str) -> Option<Arc<parser::Recovered>> {
let mut state = CACHE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (id, _) = cached_parse_in(&mut state, path, content, PARSE_CACHE_CAP);
state.entries.get(&id).and_then(|e| e.recovered.clone())
}
#[cfg(test)]
mod tests {
use super::*;
fn unique_path(name: &str) -> PathBuf {
static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
PathBuf::from(format!("/parse-cache-test/{n}-{name}.bynk"))
}
const CLEAN: &str = "commons demo\n\ntype T = { n: Int }\n";
const BROKEN: &str = "commons demo\n\ntype T = {\n";
#[test]
fn file_id_is_stable_across_calls_for_the_same_path() {
let p = unique_path("stable");
let a = file_id_for(&p);
let b = file_id_for(&p);
assert_eq!(a, b);
}
#[test]
fn file_id_is_stable_even_after_the_files_content_changes() {
let p = unique_path("edited");
let (id_before, _) = cached_parse(&p, CLEAN);
let (id_after, _) = cached_parse(&p, "commons demo\n\ntype T = { n: String }\n");
assert_eq!(
id_before, id_after,
"a FileId is a path identity, not a content one"
);
}
#[test]
fn distinct_paths_get_distinct_file_ids() {
let a = file_id_for(&unique_path("a"));
let b = file_id_for(&unique_path("b"));
assert_ne!(a, b);
}
#[test]
fn the_first_file_a_fresh_counter_interns_is_not_the_default_file_id() {
let mut state = ParseCacheState::default();
let id = file_id_for_locked(&mut state, &unique_path("first"));
assert_eq!(id, FileId(0));
assert_ne!(id, FileId::default());
}
#[test]
fn cached_parse_returns_the_same_units_on_a_repeat_call_with_identical_content() {
let p = unique_path("repeat");
let (id1, r1) = cached_parse(&p, CLEAN);
let (id2, r2) = cached_parse(&p, CLEAN);
assert_eq!(id1, id2);
let (u1, _) = r1.expect("clean source parses");
let (u2, _) = r2.expect("clean source parses");
assert!(
Arc::ptr_eq(&u1, &u2),
"a repeat call must be served from cache, not re-parsed"
);
}
#[test]
fn cached_parse_reparses_when_content_changes() {
let p = unique_path("dirty");
let (_, r1) = cached_parse(&p, CLEAN);
let (_, r2) = cached_parse(&p, "commons demo\n\ntype T = { n: String }\n");
let (u1, _) = r1.expect("clean source parses");
let (u2, _) = r2.expect("clean source parses");
assert!(
!Arc::ptr_eq(&u1, &u2),
"a content change must trigger a fresh parse"
);
}
#[test]
fn cached_recovery_is_cached_beside_the_strict_error() {
let p = unique_path("recovery");
let r1 = cached_recovery(&p, BROKEN).expect("broken source recovers");
let r2 = cached_recovery(&p, BROKEN).expect("broken source recovers");
assert!(
Arc::ptr_eq(&r1, &r2),
"the recovery must be cached, not re-parsed"
);
assert!(
cached_parse(&p, BROKEN).1.is_err(),
"the strict result stays the error"
);
let clean = unique_path("recovery-clean");
let (_, strict) = cached_parse(&clean, "commons demo\n\nfn one() -> Int { 1 }\n");
assert!(strict.is_ok());
assert!(cached_recovery(&clean, "commons demo\n\nfn one() -> Int { 1 }\n").is_none());
}
#[test]
fn source_that_does_not_lex_has_no_recovery() {
let p = unique_path("unlexable");
let src = "commons demo\n\nfn s() -> String { \"unterminated }\n";
assert!(cached_parse(&p, src).1.is_err());
assert!(cached_recovery(&p, src).is_none());
}
#[test]
fn cached_parse_caches_the_error_for_broken_syntax() {
let p = unique_path("broken");
let (_, r1) = cached_parse(&p, BROKEN);
let (_, r2) = cached_parse(&p, BROKEN);
assert!(r1.is_err());
let e1 = r1.unwrap_err();
let e2 = r2.unwrap_err();
assert!(
Arc::ptr_eq(&e1, &e2),
"a repeat call on the same broken content must be cached too"
);
}
#[test]
fn expr_ids_never_collide_across_separate_calls() {
let p1 = unique_path("exprs-a");
let p2 = unique_path("exprs-b");
let src = "commons demo\n\nfn f(x: Int) -> Int {\n x + 1\n}\n";
let (_, r1) = cached_parse(&p1, src);
let (_, r2) = cached_parse(&p2, src);
let (u1, _) = r1.expect("parses");
let (u2, _) = r2.expect("parses");
fn fn_tail_expr_id(u: &SourceUnit) -> bynk_syntax::ast::ExprId {
let SourceUnit::Commons(c) = u else {
panic!("expected commons")
};
let bynk_syntax::ast::CommonsItem::Fn(f) = &c.items[0] else {
panic!("expected fn")
};
f.body.tail.id
}
let id1 = fn_tail_expr_id(&u1[0]);
let id2 = fn_tail_expr_id(&u2[0]);
assert_ne!(
id1, id2,
"two files parsed in separate cached_parse calls must never share an ExprId"
);
}
#[test]
fn a_renamed_file_gets_its_own_independent_entry() {
let old_path = unique_path("renamed-old");
let new_path = unique_path("renamed-new");
let (old_id, old_result) = cached_parse(&old_path, CLEAN);
let (new_id, new_result) = cached_parse(&new_path, CLEAN);
assert_ne!(old_id, new_id, "a renamed file is a new path identity");
let (old_units, _) = old_result.expect("clean source parses");
let (new_units, _) = new_result.expect("clean source parses");
assert!(
!Arc::ptr_eq(&old_units, &new_units),
"the new path must not silently reuse the old path's cache entry"
);
let (old_id_again, _) = cached_parse(&old_path, CLEAN);
assert_eq!(old_id, old_id_again);
}
#[test]
fn an_evicted_entry_reparses_correctly_rather_than_serving_stale_state() {
let mut state = ParseCacheState::default();
let cap = 2;
let survivor = PathBuf::from("/parse-cache-test/evict-survivor.bynk");
let (_, before) = cached_parse_in(&mut state, &survivor, CLEAN, cap);
let (survivor_units_before, _) = before.expect("clean source parses");
for i in 0..cap {
let p = PathBuf::from(format!("/parse-cache-test/evict-filler-{i}.bynk"));
let _ = cached_parse_in(&mut state, &p, CLEAN, cap);
}
let (_, after) = cached_parse_in(&mut state, &survivor, CLEAN, cap);
let (survivor_units_after, _) = after.expect("clean source parses");
assert_eq!(
format!("{survivor_units_before:?}"),
format!("{survivor_units_after:?}"),
"a re-parsed-after-eviction file must produce the same result as before eviction"
);
}
#[test]
fn concurrent_edits_to_the_same_path_never_corrupt_the_cache() {
let path = Arc::new(unique_path("concurrent"));
let variants: Vec<String> = (0..8)
.map(|i| format!("commons demo\n\ntype T{i} = {{ n: Int }}\n"))
.collect();
std::thread::scope(|scope| {
for v in &variants {
let path = Arc::clone(&path);
scope.spawn(move || {
for _ in 0..20 {
let (_, result) = cached_parse(&path, v);
assert!(result.is_ok(), "every variant here is syntactically valid");
}
});
}
});
let (_, tail) = cached_parse(&path, CLEAN);
let (units, _) = tail.expect("clean source parses");
assert_eq!(units.len(), 1);
}
}