use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use sha2::{Digest, Sha256};
#[derive(Debug)]
pub struct ModuleSource {
text: Arc<str>,
sha256: OnceLock<[u8; 32]>,
imports: OnceLock<Vec<Arc<str>>>,
}
impl ModuleSource {
pub fn from_text(text: impl Into<Arc<str>>) -> Self {
Self {
text: text.into(),
sha256: OnceLock::new(),
imports: OnceLock::new(),
}
}
pub(crate) fn text(&self) -> &Arc<str> {
&self.text
}
pub(crate) fn as_str(&self) -> &str {
&self.text
}
pub(crate) fn sha256(&self) -> [u8; 32] {
*self.sha256.get_or_init(|| {
let mut hasher = Sha256::new();
hasher.update(self.text.as_bytes());
hasher.finalize().into()
})
}
pub(crate) fn imports(&self) -> &[Arc<str>] {
self.imports.get_or_init(|| {
collect_user_imports(&self.text)
.into_iter()
.map(Arc::from)
.collect()
})
}
}
type MemoKey = (PathBuf, u64, i128);
type Memo = Mutex<std::collections::HashMap<MemoKey, Arc<ModuleSource>>>;
fn memo() -> &'static Memo {
static MEMO: OnceLock<Memo> = OnceLock::new();
MEMO.get_or_init(|| Mutex::new(std::collections::HashMap::new()))
}
pub(crate) fn stat_identity(path: &Path) -> Option<(u64, i128)> {
let meta = fs::metadata(path).ok()?;
let len = meta.len();
let mtime_ns = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_nanos() as i128)
.unwrap_or(0);
Some((len, mtime_ns))
}
pub(crate) fn now_ns() -> i128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as i128)
.unwrap_or(0)
}
pub(crate) const TIMESTAMP_GRANULARITY_NS: i128 = 2_000_000_000;
pub(crate) fn mtime_predates_capture(mtime_ns: i128, captured_ns: i128) -> bool {
mtime_ns.saturating_add(TIMESTAMP_GRANULARITY_NS) <= captured_ns
}
pub(crate) fn canonical_identity(path: &Path) -> PathBuf {
use std::sync::OnceLock;
static MEMO: OnceLock<std::sync::Mutex<std::collections::HashMap<PathBuf, PathBuf>>> =
OnceLock::new();
let memo = MEMO.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
if let Some(hit) = memo.lock().unwrap().get(path).cloned() {
return hit;
}
match path.canonicalize() {
Ok(canonical) => {
memo.lock()
.unwrap()
.insert(path.to_path_buf(), canonical.clone());
canonical
}
Err(_) => path.to_path_buf(),
}
}
#[cfg(test)]
thread_local! {
pub(crate) static SOURCE_READS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
pub(crate) fn read(path: &Path) -> io::Result<Arc<ModuleSource>> {
#[cfg(test)]
SOURCE_READS.with(|c| c.set(c.get() + 1));
let path = canonical_identity(path);
let Some((len, mtime_ns)) = stat_identity(&path) else {
return Ok(Arc::new(ModuleSource::from_text(fs::read_to_string(
&path,
)?)));
};
let key = (path.clone(), len, mtime_ns);
if let Some(hit) = memo().lock().unwrap().get(&key).cloned() {
return Ok(hit);
}
let source = Arc::new(ModuleSource::from_text(fs::read_to_string(&path)?));
memo().lock().unwrap().insert(key, Arc::clone(&source));
Ok(source)
}
fn collect_user_imports(source: &str) -> Vec<String> {
let bytes = source.as_bytes();
let mut out: Vec<String> = Vec::new();
let mut i = 0;
while i < bytes.len() {
if let Some(end) = comment_end(bytes, i) {
i = end;
continue;
}
if bytes[i] == b'"' {
i = string_literal_end(bytes, i).unwrap_or(i + 1);
continue;
}
if !matches_keyword(bytes, i, b"import") {
i += 1;
continue;
}
let mut j = i + b"import".len();
let mut depth = 0i32;
while j < bytes.len() {
if let Some(end) = comment_end(bytes, j) {
j = end;
continue;
}
match bytes[j] {
b'"' => {
if let Some((path, end)) = read_string_literal(bytes, j) {
if !path.starts_with("std/") {
out.push(path);
}
i = end;
break;
}
j += 1;
}
b'{' => {
depth += 1;
j += 1;
}
b'}' => {
depth -= 1;
j += 1;
}
b'\n' if depth == 0 => {
i = j;
break;
}
_ => j += 1,
}
}
if j >= bytes.len() {
break;
}
if i < j {
i = j;
}
}
out
}
fn comment_end(bytes: &[u8], at: usize) -> Option<usize> {
if bytes[at] != b'/' || at + 1 >= bytes.len() {
return None;
}
match bytes[at + 1] {
b'/' => {
let mut i = at + 2;
while i < bytes.len() && bytes[i] != b'\n' {
i += 1;
}
Some(i)
}
b'*' => {
let mut i = at + 2;
while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
i += 1;
}
Some((i + 2).min(bytes.len()))
}
_ => None,
}
}
fn string_literal_end(bytes: &[u8], at: usize) -> Option<usize> {
debug_assert_eq!(bytes[at], b'"');
let mut i = at + 1;
while i < bytes.len() {
match bytes[i] {
b'"' => return Some(i + 1),
b'\\' if i + 1 < bytes.len() => i += 2,
b'\\' => return None,
b'\n' => return None,
_ => i += 1,
}
}
None
}
fn matches_keyword(bytes: &[u8], at: usize, keyword: &[u8]) -> bool {
let end = at + keyword.len();
if end > bytes.len() {
return false;
}
if &bytes[at..end] != keyword {
return false;
}
if at > 0 && is_ident_char(bytes[at - 1]) {
return false;
}
if end < bytes.len() && is_ident_char(bytes[end]) {
return false;
}
true
}
fn is_ident_char(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
fn read_string_literal(bytes: &[u8], at: usize) -> Option<(String, usize)> {
debug_assert_eq!(bytes[at], b'"');
let mut out = String::new();
let mut i = at + 1;
while i < bytes.len() {
match bytes[i] {
b'"' => return Some((out, i + 1)),
b'\\' => {
if i + 1 >= bytes.len() {
return None;
}
match bytes[i + 1] {
b'"' => out.push('"'),
b'\\' => out.push('\\'),
b'n' => out.push('\n'),
b'r' => out.push('\r'),
b't' => out.push('\t'),
other => out.push(other as char),
}
i += 2;
}
b'\n' => return None,
byte => {
out.push(byte as char);
i += 1;
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn collect_user_imports_ignores_stdlib_and_comments() {
let source = r#"
// import "comment/should/be/ignored"
import "std/agents"
import { foo } from "pkg/bar"
import "./relative/path"
"#;
let imports = collect_user_imports(source);
assert_eq!(imports, vec!["pkg/bar", "./relative/path"]);
}
#[test]
fn import_path_inside_string_literal_is_ignored() {
let source = r#"
const payload = "import { foo } from \"./other\""
import "./real"
"#;
assert_eq!(collect_user_imports(source), vec!["./real".to_string()]);
}
#[test]
fn comments_around_and_inside_an_import_clause_are_stepped_over() {
let source = concat!(
"/* leading */ import \"./before\"\n",
"import /* between */ \"./between\"\n",
"import /* spans\na line */ \"./across\"\n",
"import \"./trailing\" // after\n",
"/* import \"./blocked/out\" */\n",
);
assert_eq!(
collect_user_imports(source),
vec!["./before", "./between", "./across", "./trailing"],
"a block comment must not hide an import, or expose a commented-out one"
);
}
#[test]
fn an_escaped_quote_does_not_end_the_string_it_appears_in() {
let source = "const s = \"a \\\" import \\\"./fake\\\"\"\nimport \"./real\"\n";
assert_eq!(collect_user_imports(source), vec!["./real".to_string()]);
}
#[test]
fn an_unterminated_block_comment_hides_the_rest_of_the_file() {
let source = "import \"./seen\"\n/* unterminated\nimport \"./hidden\"\n";
assert_eq!(collect_user_imports(source), vec!["./seen".to_string()]);
}
#[test]
fn derived_facts_are_computed_once_and_match_direct_derivation() {
let source = ModuleSource::from_text("import \"./dep\"\npub fn v() -> int { return 1 }\n");
let text = source.as_str().to_string();
assert_eq!(source.sha256(), source.sha256());
assert_eq!(
source.sha256(),
<[u8; 32]>::from(Sha256::digest(text.as_bytes())),
"the memoized digest must equal a direct SHA-256 of the same bytes"
);
assert_eq!(source.imports(), [Arc::<str>::from("./dep")]);
}
#[test]
fn repeated_reads_of_an_unchanged_file_share_one_allocation() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("module.harn");
std::fs::write(&path, "import \"./first\"\nimport \"./second\"\n").unwrap();
let first = read(&path).unwrap();
let second = read(&path).unwrap();
assert!(
Arc::ptr_eq(&first, &second),
"a memo hit must reuse the source instead of reading and copying it again"
);
assert_eq!(first.imports().len(), 2);
}
#[test]
fn every_spelling_of_one_file_shares_a_single_read() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("lib/runtime")).unwrap();
std::fs::create_dir_all(tmp.path().join("mode")).unwrap();
let direct = tmp.path().join("lib/runtime/util.harn");
std::fs::write(&direct, "pub fn v() -> int { return 1 }\n").unwrap();
std::fs::create_dir_all(tmp.path().join("lib/host")).unwrap();
let spellings = [
tmp.path().join("mode/../lib/runtime/util.harn"),
tmp.path().join("lib/runtime/./util.harn"),
tmp.path().join("lib/host/../runtime/util.harn"),
];
let first = read(&direct).unwrap();
for spelling in &spellings {
assert!(
Arc::ptr_eq(&first, &read(spelling).unwrap()),
"{} names the same file and must share its single read",
spelling.display()
);
}
}
#[test]
fn a_same_length_edit_is_re_read_in_a_warm_process() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("leaf.harn");
std::fs::write(&path, "pub fn x() -> int { return 111 }\n").unwrap();
let before = read(&path).unwrap();
std::fs::write(&path, "pub fn x() -> int { return 222 }\n").unwrap();
let future = std::fs::metadata(&path).unwrap().modified().unwrap()
+ std::time::Duration::from_secs(10);
std::fs::OpenOptions::new()
.write(true)
.open(&path)
.unwrap()
.set_times(std::fs::FileTimes::new().set_modified(future))
.unwrap();
assert_eq!(
std::fs::metadata(&path).unwrap().len(),
33,
"the two versions must be the same byte length for this test to \
exercise the mtime path"
);
let after = read(&path).unwrap();
assert_ne!(
before.as_str(),
after.as_str(),
"a same-length edit must be re-read rather than served from the memo"
);
assert_ne!(before.sha256(), after.sha256());
}
#[test]
fn only_an_mtime_a_full_granularity_older_than_the_capture_counts_as_settled() {
let capture = 1_000 * TIMESTAMP_GRANULARITY_NS;
assert!(mtime_predates_capture(
capture - TIMESTAMP_GRANULARITY_NS,
capture
));
assert!(!mtime_predates_capture(
capture - TIMESTAMP_GRANULARITY_NS + 1,
capture
));
assert!(!mtime_predates_capture(capture, capture));
assert!(
!mtime_predates_capture(capture + TIMESTAMP_GRANULARITY_NS, capture),
"an mtime ahead of the clock, as a skewed network filesystem \
produces, must never be treated as settled"
);
assert!(
!mtime_predates_capture(i128::MAX, capture),
"the granularity offset must not overflow into a false settle"
);
}
#[test]
fn a_missing_file_reports_the_io_error_without_memoizing_it() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("appears-later.harn");
let missing = read(&path).unwrap_err();
assert_eq!(missing.kind(), io::ErrorKind::NotFound);
std::fs::write(&path, "pub fn v() -> int { return 1 }\n").unwrap();
assert_eq!(
read(&path).unwrap().as_str(),
"pub fn v() -> int { return 1 }\n",
"a file that appears after a failed read must be readable"
);
}
}