use std::path::{Path, PathBuf};
#[derive(Clone, Debug)]
pub struct Languages {
pub supported: Vec<String>,
pub strict: bool,
pub path: PathBuf,
}
#[derive(Clone, Debug, Default)]
pub struct OptionalModules {
pub ids: Vec<String>,
pub manifests: Vec<PathBuf>,
}
fn nearest_manifest() -> Option<PathBuf> {
let manifest = std::env::var("CARGO_MANIFEST_DIR").ok()?;
let mut directory = Some(PathBuf::from(manifest));
while let Some(current) = directory {
let candidate = current.join("Bern.toml");
if candidate.is_file() {
return Some(candidate);
}
directory = current.parent().map(PathBuf::from);
}
None
}
fn read(path: &Path) -> Result<toml::Value, String> {
let text = std::fs::read_to_string(path)
.map_err(|error| format!("cannot read {}: {error}", path.display()))?;
text.parse()
.map_err(|error| format!("{} is not valid TOML: {error}", path.display()))
}
pub fn languages() -> Result<Option<Languages>, String> {
match nearest_manifest() {
Some(path) => parse(path).map(Some),
None => Ok(None),
}
}
fn parse(path: PathBuf) -> Result<Languages, String> {
let value = read(&path)?;
let languages = value
.get("languages")
.ok_or_else(|| format!("{} needs a [languages] table", path.display()))?;
let supported: Vec<String> = languages
.get("supported")
.and_then(toml::Value::as_array)
.ok_or_else(|| "[languages] needs `supported = [\"…\"]`".to_string())?
.iter()
.filter_map(|value| value.as_str().map(str::to_string))
.collect();
let strict = languages
.get("strict")
.and_then(toml::Value::as_bool)
.unwrap_or(true);
Ok(Languages {
supported,
strict,
path,
})
}
fn application_manifest() -> Option<PathBuf> {
let mut dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").ok()?);
loop {
let path = dir.join("Bern.toml");
if path.is_file() && read(&path).ok().is_some_and(|v| v.get("app").is_some()) {
return Some(path);
}
if !dir.pop() {
return None;
}
}
}
fn excluded_for(path: &Path) -> Vec<String> {
let root = std::env::var_os("BERN_BUILD_ROOT").map(PathBuf::from);
if root.and_then(|p| p.canonicalize().ok()) != path.parent().and_then(|p| p.canonicalize().ok())
{
return Vec::new();
}
std::env::var("BERN_EXCLUDE_MODULES")
.unwrap_or_default()
.split(',')
.filter(|id| !id.is_empty())
.map(str::to_string)
.collect()
}
pub fn build_modules() -> syn::Result<bern_syntax::build::Modules> {
let Some(path) = application_manifest() else {
return Ok(Default::default());
};
let result = (|| -> Result<_, String> {
let app = read(&path)?;
let excluded = excluded_for(&path);
let modules = all_module_manifests(&path, &app)?
.into_iter()
.filter_map(|(_, module)| {
let id = module.get("module")?.get("id")?.as_str()?.to_string();
let enabled = !excluded.contains(&id);
Some((id, enabled))
})
.collect();
Ok(bern_syntax::build::Modules(modules))
})();
result.map_err(|e| syn::Error::new(proc_macro2::Span::call_site(), e))
}
pub fn track_build() -> proc_macro2::TokenStream {
let manifest: Vec<_> = application_manifest()
.map(|p| p.display().to_string())
.into_iter()
.collect();
quote::quote! {
const _: Option<&str> = ::core::option_env!("BERN_BUILD_ROOT");
const _: Option<&str> = ::core::option_env!("BERN_EXCLUDE_MODULES");
#(const _: &[u8] = ::core::include_bytes!(#manifest);)*
}
}
fn module_manifests(path: &Path, app: &toml::Value) -> Result<Vec<(PathBuf, toml::Value)>, String> {
let excluded = excluded_for(path);
Ok(all_module_manifests(path, app)?
.into_iter()
.filter(|(_, module)| {
!module
.get("module")
.and_then(|m| m.get("id"))
.and_then(toml::Value::as_str)
.is_some_and(|id| excluded.iter().any(|e| e == id))
})
.collect())
}
fn all_module_manifests(
path: &Path,
app: &toml::Value,
) -> Result<Vec<(PathBuf, toml::Value)>, String> {
let dir = path.parent().unwrap_or(Path::new("."));
let entries = app.get("modules").and_then(toml::Value::as_array);
entries
.into_iter()
.flatten()
.filter_map(|entry| entry.get("path").and_then(toml::Value::as_str))
.map(|module_dir| {
let manifest = dir.join(module_dir).join("Bern.toml");
read(&manifest).map(|module| (manifest, module))
})
.collect()
}
pub fn imported_types(namespace: &str) -> Result<(bern_protocol::TypeTable, Vec<PathBuf>), String> {
let Some(path) = application_manifest() else {
return Ok(Default::default());
};
let app = read(&path)?;
let modules = module_manifests(&path, &app)?;
let selection = build_modules().map_err(|error| error.to_string())?;
let mut files = Vec::new();
let mut done = std::collections::BTreeMap::new();
let imported = imports_of(namespace, &modules, &selection, &mut done, &mut files)?;
Ok((imported, files))
}
fn depends_of(module: &toml::Value) -> Vec<String> {
module
.get("module")
.and_then(|module| module.get("depends"))
.and_then(toml::Value::as_array)
.into_iter()
.flatten()
.filter_map(|id| id.as_str().map(str::to_string))
.collect()
}
fn imports_of(
id: &str,
modules: &[(PathBuf, toml::Value)],
selection: &bern_syntax::build::Modules,
done: &mut std::collections::BTreeMap<String, bern_protocol::TypeTable>,
files: &mut Vec<PathBuf>,
) -> Result<bern_protocol::TypeTable, String> {
let module_id = |module: &toml::Value| {
module
.get("module")
.and_then(|module| module.get("id"))
.and_then(toml::Value::as_str)
.map(str::to_string)
};
let Some((_, module)) = modules
.iter()
.find(|(_, module)| module_id(module).as_deref() == Some(id))
else {
return Ok(Default::default());
};
let mut imported = bern_protocol::TypeTable::default();
for dependency in depends_of(module) {
if !done.contains_key(&dependency) {
let types = protocol_types(&dependency, modules, selection, done, files)?;
done.insert(dependency.clone(), types);
}
imported
.merge(&done[&dependency])
.map_err(|error| format!("modules `{id}` depends on: {}", error.message))?;
}
Ok(imported)
}
fn protocol_types(
id: &str,
modules: &[(PathBuf, toml::Value)],
selection: &bern_syntax::build::Modules,
done: &mut std::collections::BTreeMap<String, bern_protocol::TypeTable>,
files: &mut Vec<PathBuf>,
) -> Result<bern_protocol::TypeTable, String> {
let imported = imports_of(id, modules, selection, done, files)?;
let Some((manifest, module)) = modules.iter().find(|(_, module)| {
module
.get("module")
.and_then(|m| m.get("id"))
.and_then(toml::Value::as_str)
== Some(id)
}) else {
return Ok(Default::default());
};
let Some(source) = module
.get("module")
.and_then(|module| module.get("protocol"))
.and_then(|protocol| protocol.get("source"))
.and_then(toml::Value::as_str)
else {
return Ok(Default::default());
};
let file = manifest.parent().unwrap_or(Path::new(".")).join(source);
let text = std::fs::read_to_string(&file)
.map_err(|error| format!("cannot read {}: {error}", file.display()))?;
files.push(file.clone());
let items = bern_syntax::items::parse_source_file_with_modules(&text, selection)
.map_err(|error| format!("{}: {error}", file.display()))?;
let Some((_, protocol)) = items.protocols.first() else {
return Ok(Default::default());
};
let descriptor = bern_syntax::lower::protocol_descriptor_with(protocol, &imported)
.map_err(|error| format!("{}: {error}", file.display()))?;
Ok(descriptor.types)
}
pub fn optional_modules() -> Result<OptionalModules, String> {
match (nearest_manifest(), std::env::var("CARGO_PKG_NAME")) {
(Some(path), Ok(package)) => optional_modules_of(&path, &package),
_ => Ok(OptionalModules::default()),
}
}
fn optional_modules_of(path: &Path, package: &str) -> Result<OptionalModules, String> {
let app = read(path)?;
let shell = app
.get("protocol")
.and_then(|protocol| protocol.get("package"))
.and_then(toml::Value::as_str);
if shell != Some(package) {
return Ok(OptionalModules::default());
}
let mut modules = OptionalModules::default();
for (manifest, module) in module_manifests(path, &app)? {
modules.manifests.push(manifest);
let module = module.get("module");
let optional = module
.and_then(|module| module.get("optional"))
.and_then(toml::Value::as_bool)
.unwrap_or(false);
let id = module
.and_then(|module| module.get("id"))
.and_then(toml::Value::as_str);
if let (true, Some(id)) = (optional, id) {
modules.ids.push(id.to_string());
}
}
Ok(modules)
}
#[derive(Clone, Debug)]
pub struct ModulePage {
pub id: String,
pub label: Vec<(String, String)>,
pub start: bool,
}
pub fn module_pages(slot: &str) -> Result<(Vec<ModulePage>, Vec<PathBuf>), String> {
match nearest_manifest() {
Some(path) => module_pages_of(&path, slot),
None => Ok(Default::default()),
}
}
fn module_pages_of(path: &Path, slot: &str) -> Result<(Vec<ModulePage>, Vec<PathBuf>), String> {
let app = read(path)?;
let mut pages = Vec::new();
let mut manifests = Vec::new();
for (manifest, module) in module_manifests(path, &app)? {
manifests.push(manifest);
let entries = module.get("pages").and_then(toml::Value::as_array);
for page in entries.into_iter().flatten() {
if page.get("slot").and_then(toml::Value::as_str) != Some(slot) {
continue;
}
let Some(id) = page.get("id").and_then(toml::Value::as_str) else {
continue;
};
let label = page
.get("label")
.and_then(toml::Value::as_table)
.into_iter()
.flatten()
.filter_map(|(language, text)| Some((language.clone(), text.as_str()?.to_string())))
.collect();
pages.push(ModulePage {
id: id.to_string(),
label,
start: page
.get("start")
.and_then(toml::Value::as_bool)
.unwrap_or(false),
});
}
}
Ok((pages, manifests))
}
#[cfg(test)]
mod tests {
use super::*;
fn application(name: &str) -> PathBuf {
let root =
std::env::temp_dir().join(format!("bern-macros-config-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
for module in ["notes", "sync"] {
std::fs::create_dir_all(root.join("modules").join(module)).unwrap();
}
std::fs::write(
root.join("Bern.toml"),
"[protocol]\npackage = \"app-protocol\"\n\n\
[[modules]]\npath = \"modules/notes\"\n\n\
[[modules]]\npath = \"modules/sync\"\n",
)
.unwrap();
std::fs::write(
root.join("modules/notes/Bern.toml"),
"[module]\nid = \"notes\"\noptional = true\n\n\
[[pages]]\nslot = \"main\"\nid = \"recent_notes\"\n\
label = { \"en\" = \"Recent notes\", \"ja\" = \"最近のメモ\" }\n\n\
[[pages]]\nslot = \"settings\"\nid = \"notes\"\nlabel = { \"en\" = \"Notes\" }\n",
)
.unwrap();
std::fs::write(
root.join("modules/sync/Bern.toml"),
"[module]\nid = \"sync\"\n",
)
.unwrap();
root.join("Bern.toml")
}
#[test]
fn the_shell_protocol_gets_the_optional_modules() {
let manifest = application("optional");
let modules = optional_modules_of(&manifest, "app-protocol").unwrap();
assert_eq!(modules.ids, ["notes"]);
assert_eq!(modules.manifests.len(), 2);
let other = optional_modules_of(&manifest, "app-ui").unwrap();
assert!(other.ids.is_empty());
let _ = std::fs::remove_dir_all(manifest.parent().unwrap());
}
#[test]
fn a_page_enum_gets_the_pages_of_its_slot() {
let manifest = application("pages");
let (pages, manifests) = module_pages_of(&manifest, "main").unwrap();
assert_eq!(manifests.len(), 2);
assert_eq!(pages.len(), 1);
assert_eq!(pages[0].id, "recent_notes");
let mut label = pages[0].label.clone();
label.sort();
assert_eq!(
label,
[
("en".to_string(), "Recent notes".to_string()),
("ja".to_string(), "最近のメモ".to_string()),
]
);
let (none, _) = module_pages_of(&manifest, "sidebar").unwrap();
assert!(none.is_empty());
let _ = std::fs::remove_dir_all(manifest.parent().unwrap());
}
#[test]
fn a_missing_module_manifest_is_named() {
let manifest = application("missing");
std::fs::remove_file(manifest.parent().unwrap().join("modules/sync/Bern.toml")).unwrap();
let error = optional_modules_of(&manifest, "app-protocol").unwrap_err();
assert!(error.contains("modules/sync/Bern.toml"), "{error}");
let _ = std::fs::remove_dir_all(manifest.parent().unwrap());
}
}