use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use bynk_syntax::ast::{
AdapterDecl, Case, Commons, CommonsItem, ConsumesDecl, ExportsDecl, SourceUnit, SuiteDecl,
TestTier, Trivia, TypeRef, UsesDecl,
};
use bynk_syntax::error::CompileError;
use bynk_syntax::span::Span;
use crate::roots::{Roots, UnitKind};
pub fn case_effective_tier(case: &Case, suite: &SuiteDecl) -> TestTier {
case.tier.or(suite.tier).unwrap_or(TestTier::Unit)
}
pub fn suite_effective_tier_is_system(suite: &SuiteDecl) -> bool {
suite.tier == Some(TestTier::System)
|| suite.cases.iter().any(|c| c.tier == Some(TestTier::System))
}
pub fn read_source(path: &Path, overlay: &HashMap<PathBuf, String>) -> std::io::Result<String> {
if let Some(text) = overlay.get(path) {
return Ok(text.clone());
}
let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
if let Some(text) = overlay.get(&canonical) {
return Ok(text.clone());
}
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("no overlay entry for `{}`", path.display()),
))
}
pub fn read_adapter_binding(
path: &Path,
overlay: &HashMap<PathBuf, String>,
) -> std::io::Result<String> {
if let Some(text) = overlay.get(path) {
return Ok(text.clone());
}
let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
if let Some(text) = overlay.get(&canonical) {
return Ok(text.clone());
}
fs::read_to_string(path)
}
#[derive(Clone)]
pub struct ParsedFile {
pub(crate) source_path: PathBuf,
pub(crate) identity_path: PathBuf,
pub(crate) abs_path: Option<PathBuf>,
pub(crate) source: String,
pub(crate) unit: SourceUnit,
pub(crate) kind: UnitKind,
pub(crate) synthetic: bool,
}
impl ParsedFile {
pub fn synthetic(
identity_path: PathBuf,
source_path: PathBuf,
source: String,
unit: SourceUnit,
kind: UnitKind,
) -> Self {
ParsedFile {
source_path,
identity_path,
abs_path: None,
source,
unit,
kind,
synthetic: true,
}
}
#[allow(clippy::too_many_arguments)]
pub fn new(
source_path: PathBuf,
identity_path: PathBuf,
abs_path: Option<PathBuf>,
source: String,
unit: SourceUnit,
kind: UnitKind,
synthetic: bool,
) -> Self {
ParsedFile {
source_path,
identity_path,
abs_path,
source,
unit,
kind,
synthetic,
}
}
pub fn source_path(&self) -> PathBuf {
self.source_path.clone()
}
pub fn identity_path(&self) -> PathBuf {
self.identity_path.clone()
}
pub fn abs_path(&self) -> Option<PathBuf> {
self.abs_path.clone()
}
pub fn kind(&self) -> UnitKind {
self.kind
}
pub fn set_kind(&mut self, kind: UnitKind) {
self.kind = kind;
}
pub fn is_synthetic(&self) -> bool {
self.synthetic
}
pub fn unit(&self) -> &SourceUnit {
&self.unit
}
pub fn unit_mut(&mut self) -> &mut SourceUnit {
&mut self.unit
}
pub fn source(&self) -> &str {
&self.source
}
pub fn map_source_name(&self) -> String {
self.abs_path
.as_deref()
.unwrap_or(self.source_path.as_path())
.to_string_lossy()
.replace('\\', "/")
}
pub fn items(&self) -> &Vec<CommonsItem> {
match &self.unit {
SourceUnit::Commons(c) => &c.items,
SourceUnit::Context(c) => &c.items,
SourceUnit::Adapter(a) => &a.items,
SourceUnit::Suite(_) => {
static EMPTY: std::sync::OnceLock<Vec<CommonsItem>> = std::sync::OnceLock::new();
EMPTY.get_or_init(Vec::new)
}
}
}
pub fn declares_messages(&self) -> bool {
self.items()
.iter()
.any(|it| matches!(it, CommonsItem::Messages(_)))
}
pub fn uses(&self) -> &Vec<UsesDecl> {
match &self.unit {
SourceUnit::Commons(c) => &c.uses,
SourceUnit::Context(c) => &c.uses,
SourceUnit::Adapter(a) => &a.uses,
SourceUnit::Suite(t) => &t.uses,
}
}
pub fn consumes(&self) -> &[ConsumesDecl] {
match &self.unit {
SourceUnit::Commons(_) => &[],
SourceUnit::Context(c) => &c.consumes,
SourceUnit::Adapter(a) => &a.consumes,
SourceUnit::Suite(_) => &[],
}
}
pub fn exports(&self) -> &[ExportsDecl] {
match &self.unit {
SourceUnit::Context(c) => &c.exports,
SourceUnit::Adapter(a) => &a.exports,
_ => &[],
}
}
pub fn adapter(&self) -> Option<&AdapterDecl> {
match &self.unit {
SourceUnit::Adapter(a) => Some(a),
_ => None,
}
}
pub fn test(&self) -> Option<&SuiteDecl> {
match &self.unit {
SourceUnit::Suite(t) => Some(t),
_ => None,
}
}
pub fn history_target_agent_names(&self) -> impl Iterator<Item = &str> {
self.test()
.into_iter()
.flat_map(|t| &t.properties)
.flat_map(|prop| &prop.forall.bindings)
.filter_map(|b| match &b.type_ref {
TypeRef::History(inner, _) => match inner.as_ref() {
TypeRef::Named(id) => Some(id.name.as_str()),
_ => None,
},
_ => None,
})
}
pub fn integration(&self) -> Option<&SuiteDecl> {
match &self.unit {
SourceUnit::Suite(t) if suite_effective_tier_is_system(t) => Some(t),
_ => None,
}
}
pub fn as_synthetic_commons(&self, items: Vec<CommonsItem>) -> Commons {
let (name, uses, documentation, form, span) = match &self.unit {
SourceUnit::Commons(c) => (
c.name.clone(),
c.uses.clone(),
c.documentation.clone(),
c.form,
c.span,
),
SourceUnit::Context(c) => (
c.name.clone(),
c.uses.clone(),
c.documentation.clone(),
c.form,
c.span,
),
SourceUnit::Suite(t) => (
t.target.clone(),
t.uses.clone(),
t.documentation.clone(),
t.form,
t.span,
),
SourceUnit::Adapter(a) => (
a.name.clone(),
a.uses.clone(),
a.documentation.clone(),
a.form,
a.span,
),
};
Commons {
name,
items,
uses,
documentation,
form,
span,
trivia: Trivia::default(),
trailing_comments: Vec::new(),
}
}
}
pub fn parse_sources(
root: &Path,
prefix: &Path,
path: &Path,
source: String,
) -> Result<(Vec<ParsedFile>, Vec<CompileError>), Vec<CompileError>> {
let abs_path = std::path::absolute(path).ok();
let cache_key: &Path = abs_path.as_deref().unwrap_or(path);
let (_file_id, result) = crate::parse_cache::cached_parse(cache_key, &source);
let (units, warnings) = result.map_err(|errors| (*errors).clone())?;
let rel = path.strip_prefix(root).unwrap_or(path).to_path_buf();
let files = units
.iter()
.cloned()
.map(|unit| {
let kind = match &unit {
SourceUnit::Commons(_) => UnitKind::Commons,
SourceUnit::Context(_) => UnitKind::Context,
SourceUnit::Suite(t) if suite_effective_tier_is_system(t) => UnitKind::Integration,
SourceUnit::Suite(_) => UnitKind::Test,
SourceUnit::Adapter(_) => UnitKind::Adapter,
};
ParsedFile {
abs_path: abs_path.clone(),
identity_path: prefix.join(&rel),
source_path: rel.clone(),
source: source.clone(),
unit,
kind,
synthetic: false,
}
})
.collect();
Ok((files, (*warnings).clone()))
}
pub fn discover_bynk_files(
root: &Path,
excludes: &[PathBuf],
) -> Result<Vec<PathBuf>, CompileError> {
if !root.exists() {
return Err(CompileError::new(
"bynk.project.no_root",
Span::default(),
format!("project root does not exist: {}", root.display()),
));
}
let is_excluded = |dir: &Path| {
excludes.iter().any(|ex| dir == ex || dir.starts_with(ex))
|| dir
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with('.') && n != ".")
};
let mut out = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let rd = match fs::read_dir(&dir) {
Ok(r) => r,
Err(e) => {
return Err(CompileError::new(
"bynk.project.read_failed",
Span::default(),
format!("could not read directory `{}`: {e}", dir.display()),
));
}
};
for entry in rd.flatten() {
let p = entry.path();
if p.is_dir() {
if !is_excluded(&p) {
stack.push(p);
}
} else if p.extension().and_then(|e| e.to_str()) == Some("bynk") {
out.push(p);
}
}
}
out.sort();
Ok(out)
}
pub fn discover_project_files(roots: &Roots) -> Vec<PathBuf> {
let trees = roots.trees();
let excludes = roots.excludes();
let mut out = Vec::new();
for (root, _prefix) in &trees {
out.extend(discover_bynk_files(root, &excludes).unwrap_or_default());
}
out.sort();
out.dedup();
out
}
pub fn check_file_directory_conflicts(
root: &Path,
files: &[PathBuf],
) -> Result<(), Vec<CompileError>> {
let mut errors: Vec<CompileError> = Vec::new();
let mut bynk_files: HashSet<PathBuf> = HashSet::new();
let mut dirs_with_bynk: HashSet<PathBuf> = HashSet::new();
for p in files {
let rel = p.strip_prefix(root).unwrap_or(p);
bynk_files.insert(rel.to_path_buf());
if let Some(parent) = rel.parent() {
dirs_with_bynk.insert(parent.to_path_buf());
}
}
for f in &bynk_files {
let stem = f.with_extension("");
if dirs_with_bynk.contains(&stem) {
errors.push(
CompileError::new(
"bynk.project.file_and_directory",
Span::default(),
format!(
"commons at `{}` is ambiguous: both `{}` and `{}/` exist with `.bynk` content",
f.with_extension("").display(),
f.display(),
stem.display()
),
)
.with_note(
"a commons can be a single `.bynk` file OR a directory of `.bynk` files, not both",
),
);
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_missing_root_is_reported_as_no_root() {
let root = std::env::temp_dir().join(format!(
"bynk-discovery-no-root-{}-does-not-exist",
std::process::id()
));
let err =
discover_bynk_files(&root, &[]).expect_err("a missing root must not discover files");
assert_eq!(err.category, "bynk.project.no_root");
}
#[test]
fn read_source_finds_a_synthetic_overlay_path_that_does_not_exist_on_disk() {
let path = PathBuf::from("./__bynk_in_memory__/t.bynk");
let mut overlay = HashMap::new();
overlay.insert(path.clone(), "context t\n".to_string());
let got = read_source(&path, &overlay).expect("the overlay entry must be found");
assert_eq!(got, "context t\n");
}
#[test]
fn read_source_errors_on_a_real_file_with_no_overlay_entry_rather_than_reading_disk() {
let dir = std::env::temp_dir().join(format!(
"bynk-emit-discovery-fallback-test-{}",
std::process::id()
));
std::fs::create_dir_all(&dir).expect("create test dir");
let path = dir.join("t.bynk");
std::fs::write(&path, "context t\n").expect("write real file");
let got = read_source(&path, &HashMap::new());
std::fs::remove_dir_all(&dir).ok();
assert!(
got.is_err(),
"a real file with no overlay entry must not be silently read from disk"
);
assert_eq!(got.unwrap_err().kind(), std::io::ErrorKind::NotFound);
}
}