use std::borrow::Cow;
use std::collections::BTreeMap;
use std::io;
use std::path::Path;
use std::sync::Arc;
use brink_compiler::{CompileError, CompileOutput, ResolvedDiagnostic};
use brink_driver::{AnalysisOptions, Dialect, Driver, TypePolicy};
use brink_ir::Diagnostic;
use brink_project_config::{ConfigError, discover_from_entry_in_tree, parse_str};
use brink_source_tree::SourceTree;
fn fnv1a_64(bytes: &[u8]) -> u64 {
const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
const PRIME: u64 = 0x0000_0100_0000_01b3;
let mut hash = OFFSET;
for &byte in bytes {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(PRIME);
}
hash
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
)]
pub struct ContentHash(u64);
impl ContentHash {
#[must_use]
pub fn of(text: &str) -> Self {
Self(fnv1a_64(text.as_bytes()))
}
}
impl std::fmt::Display for ContentHash {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:016x}", self.0)
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
)]
pub struct EnvHash(u64);
impl std::fmt::Display for EnvHash {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:016x}", self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum ContentStore {
Inline(BTreeMap<ContentHash, String>),
}
impl ContentStore {
fn get(&self, hash: ContentHash) -> Option<&str> {
match self {
Self::Inline(map) => map.get(&hash).map(String::as_str),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ResolvedDep {
pub module: String,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Environment {
manifest: BTreeMap<String, ContentHash>,
content: ContentStore,
pub entry: String,
pub options: AnalysisOptions,
pub resolved_deps: Vec<ResolvedDep>,
}
impl Environment {
pub fn source_keys(&self) -> impl Iterator<Item = &str> {
self.manifest.keys().map(String::as_str)
}
pub fn source_text(&self, key: &str) -> Option<Cow<'_, str>> {
let hash = *self.manifest.get(key)?;
self.content.get(hash).map(Cow::Borrowed)
}
pub fn content_hash(&self) -> EnvHash {
let mut buf: Vec<u8> = Vec::new();
for (key, hash) in &self.manifest {
buf.extend_from_slice(key.as_bytes());
buf.push(0);
buf.extend_from_slice(&hash.0.to_le_bytes());
}
buf.push(0xff);
buf.extend_from_slice(self.entry.as_bytes());
buf.push(0xff);
buf.extend_from_slice(&serde_json::to_vec(&self.options).unwrap_or_default());
buf.push(0xff);
buf.extend_from_slice(&serde_json::to_vec(&self.resolved_deps).unwrap_or_default());
EnvHash(fnv1a_64(&buf))
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct OptionOverrides {
pub dialect: Option<Dialect>,
pub types: Option<TypePolicy>,
}
pub struct Project;
impl Project {
pub fn load(
tree: &dyn SourceTree,
entry: &str,
overrides: &OptionOverrides,
) -> Result<Environment, LoadError> {
let sources = collect_sources(tree, entry)?;
let mut manifest = BTreeMap::new();
let mut inline = BTreeMap::new();
for (key, text) in sources {
let hash = ContentHash::of(&text);
inline.insert(hash, text);
manifest.insert(key, hash);
}
let options = resolve_options(tree, entry, overrides)?;
Ok(Environment {
manifest,
content: ContentStore::Inline(inline),
entry: entry.to_string(),
options,
resolved_deps: Vec::new(),
})
}
}
fn is_dotdot_polluted(key: &str) -> bool {
key.split('/').any(|segment| segment == "..")
}
const NATIVE_EXTENSION: &str = "brink";
fn collect_sources(
tree: &dyn SourceTree,
entry: &str,
) -> Result<BTreeMap<String, String>, LoadError> {
if brink_driver::is_native(Path::new(entry)) {
let mut map = BTreeMap::new();
for key in tree.list(Path::new("."))? {
if Path::new(&key)
.extension()
.is_none_or(|ext| ext != NATIVE_EXTENSION)
{
continue;
}
if is_dotdot_polluted(&key) {
return Err(LoadError::InvalidSourceKey(key));
}
let text = tree.read(&key)?;
map.insert(key, text);
}
Ok(map)
} else {
let mut driver = Driver::new();
driver.discover(entry, |key| tree.read(key))?;
let db = driver.db();
let mut map = BTreeMap::new();
for id in db.file_ids() {
if let (Some(path), Some(source)) = (db.file_path(id), db.source(id)) {
map.insert(path.to_string(), source.to_string());
}
}
Ok(map)
}
}
fn resolve_options(
tree: &dyn SourceTree,
entry: &str,
overrides: &OptionOverrides,
) -> Result<AnalysisOptions, LoadError> {
let mut options = AnalysisOptions::default();
if let Some(config_key) = discover_from_entry_in_tree(tree, Path::new("."), entry)? {
let text = tree.read(&config_key)?;
let (config, warnings) = parse_str(&text)?;
for warning in &warnings {
tracing::warn!("[{config_key}] {warning}");
}
options.apply_project_config(
&config,
overrides.dialect.is_some(),
overrides.types.is_some(),
);
}
if let Some(dialect) = overrides.dialect {
options.dialect = dialect;
}
if let Some(types) = overrides.types {
options.types = Some(types);
}
Ok(options)
}
pub fn compile(env: &Environment) -> Result<CompileOutput, CompileError> {
let mut driver = Driver::new();
driver.set_analysis_options(env.options.clone());
for key in env.source_keys() {
if let Some(text) = env.source_text(key) {
driver.db_mut().set_file(key, text.into_owned());
}
}
if driver.db_mut().set_entry(&env.entry).is_none() {
return Err(CompileError::Io(io::Error::new(
io::ErrorKind::NotFound,
format!("entry file not in environment: {}", env.entry),
)));
}
let product = driver.db().story_data().cloned().unwrap_or_default();
let Some(story) = product.story else {
let mut all = product.errors;
all.extend(product.warnings);
return Err(CompileError::Diagnostics(resolve_diagnostics(
driver.db(),
all,
)));
};
Ok(CompileOutput {
data: Arc::unwrap_or_clone(story),
warnings: resolve_diagnostics(driver.db(), product.warnings),
})
}
fn resolve_diagnostics(
db: &brink_driver::ProjectDb,
diags: Vec<Diagnostic>,
) -> Vec<ResolvedDiagnostic> {
diags
.into_iter()
.map(|d| ResolvedDiagnostic {
path: db.file_path(d.file).unwrap_or_default().to_string(),
file: d.file,
range: d.range,
message: d.message,
code: d.code,
})
.collect()
}
#[derive(Debug, thiserror::Error)]
pub enum LoadError {
#[error("I/O error: {0}")]
Io(#[from] io::Error),
#[error("discovery error: {0}")]
Discover(#[from] brink_driver::DiscoverError),
#[error("project config error: {0}")]
Config(#[from] ConfigError),
#[error("invalid source key `{0}` (must be root-relative, no `..`)")]
InvalidSourceKey(String),
}
#[cfg(test)]
mod tests {
use super::*;
use brink_source_tree::InMemory;
fn tree(files: &[(&str, &str)]) -> InMemory {
InMemory::new(
files
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect::<BTreeMap<_, _>>(),
)
}
#[test]
fn content_hash_is_deterministic_and_content_addressed() {
assert_eq!(ContentHash::of("hello"), ContentHash::of("hello"));
assert_ne!(ContentHash::of("hello"), ContentHash::of("world"));
}
#[test]
fn source_text_resolves_through_key_hash_store() {
let t = tree(&[("main.brink", "flow main() {}")]);
let env = Project::load(&t, "main.brink", &OptionOverrides::default()).expect("loads");
assert_eq!(
env.source_text("main.brink").as_deref(),
Some("flow main() {}")
);
assert_eq!(env.source_text("absent.brink"), None);
}
#[test]
fn identical_content_is_stored_once_but_keyed_twice() {
let t = tree(&[("a.brink", "flow a() {}"), ("b.brink", "flow a() {}")]);
let env = Project::load(&t, "a.brink", &OptionOverrides::default()).expect("loads");
let ContentStore::Inline(store) = &env.content;
assert_eq!(env.source_keys().count(), 2);
assert_eq!(store.len(), 1);
}
#[test]
fn source_keys_are_sorted() {
let t = tree(&[
("z.brink", "flow z() {}"),
("a.brink", "flow a() {}"),
("m.brink", "flow m() {}"),
]);
let env = Project::load(&t, "a.brink", &OptionOverrides::default()).expect("loads");
let keys: Vec<_> = env.source_keys().collect();
assert_eq!(keys, vec!["a.brink", "m.brink", "z.brink"]);
}
#[test]
fn environment_round_trips_through_json_unchanged() {
let t = tree(&[("main.brink", "flow main() {}")]);
let env = Project::load(&t, "main.brink", &OptionOverrides::default()).expect("loads");
let json = serde_json::to_string(&env).expect("serializes");
let back: Environment = serde_json::from_str(&json).expect("deserializes");
assert_eq!(env, back);
assert_eq!(env.content_hash(), back.content_hash());
}
#[test]
fn content_hash_changes_when_a_source_changes() {
let a = Project::load(
&tree(&[("m.brink", "flow m() {}")]),
"m.brink",
&OptionOverrides::default(),
)
.expect("loads");
let b = Project::load(
&tree(&[("m.brink", "flow m() { Hi. }")]),
"m.brink",
&OptionOverrides::default(),
)
.expect("loads");
assert_ne!(a.content_hash(), b.content_hash());
}
#[test]
fn content_hash_changes_when_options_change() {
let base = tree(&[("m.brink", "flow m() {}")]);
let default = Project::load(&base, "m.brink", &OptionOverrides::default()).expect("loads");
let overridden = Project::load(
&base,
"m.brink",
&OptionOverrides {
dialect: Some(Dialect::Brink),
types: None,
},
)
.expect("loads");
assert_ne!(default.content_hash(), overridden.content_hash());
}
#[test]
fn brink_toml_dialect_is_discovered_over_the_tree() {
let t = tree(&[
("brink.toml", "[project]\ndialect = \"brink\"\n"),
("main.brink", "flow main() {}"),
]);
let env = Project::load(&t, "main.brink", &OptionOverrides::default()).expect("loads");
assert_eq!(env.options.dialect, Dialect::Brink);
}
#[test]
fn brink_toml_is_discovered_by_walking_up_from_the_entry() {
let t = tree(&[
("brink.toml", "[project]\ndialect = \"brink\"\n"),
("chapters/main.brink", "flow main() {}"),
]);
let env =
Project::load(&t, "chapters/main.brink", &OptionOverrides::default()).expect("loads");
assert_eq!(env.options.dialect, Dialect::Brink);
}
#[test]
fn explicit_override_wins_over_brink_toml() {
let t = tree(&[
("brink.toml", "[project]\ndialect = \"brink\"\n"),
("main.brink", "flow main() {}"),
]);
let env = Project::load(
&t,
"main.brink",
&OptionOverrides {
dialect: Some(Dialect::StrictInk),
types: None,
},
)
.expect("loads");
assert_eq!(env.options.dialect, Dialect::StrictInk);
}
#[test]
fn no_brink_toml_yields_default_options() {
let t = tree(&[("main.brink", "flow main() {}")]);
let env = Project::load(&t, "main.brink", &OptionOverrides::default()).expect("loads");
assert_eq!(env.options, AnalysisOptions::default());
}
#[test]
fn malformed_brink_toml_is_a_load_error() {
let t = tree(&[
("brink.toml", "[project]\ndialect = \"sideways\"\n"),
("main.brink", "flow main() {}"),
]);
let err = Project::load(&t, "main.brink", &OptionOverrides::default())
.expect_err("invalid dialect value must fail load");
assert!(matches!(err, LoadError::Config(_)));
}
#[test]
fn native_universe_is_the_whole_tree_excluding_config() {
let t = tree(&[
("brink.toml", "[project]\n"),
("main.brink", "flow main() {}"),
("lib/util.brink", "flow util() {}"),
("README.md", "not source"),
]);
let env = Project::load(&t, "main.brink", &OptionOverrides::default()).expect("loads");
let keys: Vec<_> = env.source_keys().collect();
assert_eq!(keys, vec!["lib/util.brink", "main.brink"]);
}
#[test]
fn dotdot_native_key_is_rejected() {
struct Hostile;
impl SourceTree for Hostile {
fn list(&self, _root: &Path) -> io::Result<Vec<String>> {
Ok(vec!["a.brink".to_string(), "../escape.brink".to_string()])
}
fn read(&self, key: &str) -> io::Result<String> {
Ok(format!("-- {key} --"))
}
}
let err = Project::load(&Hostile, "a.brink", &OptionOverrides::default())
.expect_err("dotdot key must be rejected");
assert!(matches!(err, LoadError::InvalidSourceKey(k) if k == "../escape.brink"));
}
#[test]
fn ink_universe_follows_the_include_graph() {
let t = tree(&[
("main.ink", "INCLUDE lib.ink\nHello.\n-> END\n"),
("lib.ink", "== helper ==\n-> DONE\n"),
("unreferenced.ink", "== orphan ==\n-> DONE\n"),
]);
let env = Project::load(&t, "main.ink", &OptionOverrides::default()).expect("loads");
let keys: Vec<_> = env.source_keys().collect();
assert_eq!(keys, vec!["lib.ink", "main.ink"]);
}
#[test]
fn compile_over_environment_produces_story_data() {
let t = tree(&[("main.ink", "Hello, world.\n-> END\n")]);
let env = Project::load(&t, "main.ink", &OptionOverrides::default()).expect("loads");
let out = compile(&env).expect("compiles");
assert!(
!out.data.containers.is_empty(),
"expected compiled containers"
);
}
#[test]
fn compile_surfaces_diagnostics_as_a_compile_error() {
let t = tree(&[("main.ink", "VAR arr = 0\n~ { arr = #[1, 2, 3] }\n-> END\n")]);
let env = Project::load(&t, "main.ink", &OptionOverrides::default()).expect("loads");
let err = compile(&env).expect_err("strict-ink must reject extension syntax");
assert!(matches!(err, CompileError::Diagnostics(_)));
}
#[test]
fn load_then_compile_matches_across_a_serialize_round_trip() {
let t = tree(&[("main.ink", "Hello.\n-> END\n")]);
let env = Project::load(&t, "main.ink", &OptionOverrides::default()).expect("loads");
let json = serde_json::to_string(&env).expect("serializes");
let back: Environment = serde_json::from_str(&json).expect("deserializes");
let a = compile(&env).expect("compiles");
let b = compile(&back).expect("compiles from round-tripped env");
let mut buf_a = String::new();
let mut buf_b = String::new();
brink_format::write_inkt(&a.data, &mut buf_a).expect("inkt a");
brink_format::write_inkt(&b.data, &mut buf_b).expect("inkt b");
assert_eq!(buf_a, buf_b);
}
}