#![forbid(unsafe_code)]
#![warn(missing_docs)]
use std::cell::RefCell;
use std::path::{Path, PathBuf};
use zerodds_ast::Ast;
use zerodds_idl_parse::{Diagnostic, FileLoader, Severity};
use zerodds_types::resolve::TypeRegistry;
pub struct PathLoader {
roots: Vec<PathBuf>,
opened: RefCell<Vec<String>>,
}
impl PathLoader {
#[must_use]
pub fn new(roots: Vec<PathBuf>) -> Self {
Self {
roots,
opened: RefCell::new(Vec::new()),
}
}
#[must_use]
pub fn bare() -> Self {
Self::new(Vec::new())
}
#[must_use]
pub fn opened(&self) -> Vec<String> {
self.opened.borrow().clone()
}
}
impl FileLoader for PathLoader {
fn load(&self, path: &str, from: Option<&str>) -> Option<(String, String)> {
let mut candidates: Vec<PathBuf> = Vec::new();
if let Some(from) = from {
if let Some(parent) = Path::new(from).parent() {
candidates.push(parent.join(path));
}
}
candidates.extend(self.roots.iter().map(|root| root.join(path)));
candidates.push(PathBuf::from(path));
for candidate in candidates {
if let Ok(text) = std::fs::read_to_string(&candidate) {
let resolved = candidate.to_string_lossy().into_owned();
self.opened.borrow_mut().push(resolved.clone());
return Some((resolved, text));
}
}
None
}
}
pub struct Composed {
pub ast: Ast,
pub registry: TypeRegistry,
pub diagnostics: Vec<Diagnostic>,
pub without_type_objects: Vec<(String, String)>,
pub expanded: Option<zerodds_idl_parse::Preprocessed>,
}
impl Composed {
pub fn locate(&self, diagnostic: &mut Diagnostic) {
if let Some(expanded) = &self.expanded {
expanded.locate(diagnostic);
}
}
#[must_use]
pub fn failed(&self) -> bool {
self.diagnostics
.iter()
.any(|d| d.severity == Severity::Error)
}
}
#[derive(Debug, Clone)]
pub struct ComposeOptions {
pub default_extensibility: Option<zerodds_ast::Extensibility>,
pub default_nested: Option<bool>,
pub defines: Vec<(String, String)>,
pub hashed_id_advances_the_counter: zerodds_idl_lower::HashedIdCounter,
pub hashed_name_spelling: zerodds_idl_lower::HashedNameSpelling,
pub key_on_union_discriminator: zerodds_idl_lower::KeyOnUnionDiscriminator,
pub name_of_the_enclosing_scope: zerodds_idl_lower::NameOfTheEnclosingScope,
pub dialect: zerodds_idl_parse::Dialect,
}
impl Default for ComposeOptions {
fn default() -> Self {
Self {
default_extensibility: None,
default_nested: None,
defines: Vec::new(),
hashed_id_advances_the_counter: zerodds_idl_lower::HashedIdCounter::default(),
hashed_name_spelling: zerodds_idl_lower::HashedNameSpelling::default(),
key_on_union_discriminator: zerodds_idl_lower::KeyOnUnionDiscriminator::default(),
name_of_the_enclosing_scope: zerodds_idl_lower::NameOfTheEnclosingScope::default(),
dialect: zerodds_idl_parse::Dialect::ALL_VENDORS,
}
}
}
pub use zerodds_idl_lower::HashedIdCounter;
pub use zerodds_idl_lower::HashedNameSpelling;
pub use zerodds_idl_lower::KeyOnUnionDiscriminator;
pub use zerodds_idl_lower::NameOfTheEnclosingScope;
pub fn compose(path: &str, loader: &dyn FileLoader) -> Result<Composed, String> {
compose_with(path, loader, ComposeOptions::default())
}
pub fn compose_with(
path: &str,
loader: &dyn FileLoader,
options: ComposeOptions,
) -> Result<Composed, String> {
let source = std::fs::read_to_string(path).map_err(|error| format!("{path}: {error}"))?;
Ok(compose_source_with(path, &source, loader, options))
}
#[must_use]
pub fn compose_source(path: &str, source: &str, loader: &dyn FileLoader) -> Composed {
compose_source_with(path, source, loader, ComposeOptions::default())
}
#[must_use]
pub fn compose_source_with(
path: &str,
source: &str,
loader: &dyn FileLoader,
options: ComposeOptions,
) -> Composed {
let preprocessed = zerodds_idl_parse::preprocess_with(path, source, loader, &options.defines);
let parsed = zerodds_idl_parse::parse_preprocessed_with(&preprocessed, options.dialect);
let mut diagnostics = parsed.diagnostics().to_vec();
let expanded_from = preprocessed.diagnostics.len();
let tree_from = diagnostics.len();
let lowered = zerodds_idl_lower::lower_with(&parsed.syntax(), path, options.lowering());
diagnostics.extend(lowered.diagnostics);
let mut ast = lowered.ast;
diagnostics.extend(resolve(&mut ast, path));
diagnostics.extend(inadmissible_cycles(&ast));
for diagnostic in diagnostics.iter_mut().skip(tree_from) {
on_the_expanded_text(&parsed, diagnostic);
}
for diagnostic in diagnostics.iter_mut().skip(expanded_from) {
preprocessed.locate(diagnostic);
}
place_nodes_in_their_files(&mut ast, &parsed, &preprocessed, |_| true);
let mut registry = TypeRegistry::new();
let without_type_objects = zerodds_types::from_ast::register_all(&ast, &mut registry)
.map(|skipped| {
skipped
.into_iter()
.map(|(name, error)| (name, format!("{error:?}")))
.collect()
})
.unwrap_or_default();
Composed {
ast,
registry,
diagnostics,
without_type_objects,
expanded: Some(preprocessed),
}
}
fn ast_source(ast: &Ast, any: zerodds_ast::AnyId) -> Option<&zerodds_ast::SourceFacet> {
ast.facets(any).map(|facets| &facets.source)
}
fn on_the_expanded_text(parsed: &zerodds_idl_parse::Parse, diagnostic: &mut Diagnostic) {
diagnostic.primary = parsed.source_span(diagnostic.primary);
for (span, _) in &mut diagnostic.labels {
*span = parsed.source_span(*span);
}
}
fn place_nodes_in_their_files(
ast: &mut Ast,
parsed: &zerodds_idl_parse::Parse,
preprocessed: &zerodds_idl_parse::Preprocessed,
which: impl Fn(zerodds_ast::AnyId) -> bool,
) {
let mut placements = Vec::new();
for any in ast.all_nodes().filter(|any| which(*any)) {
let Some(facets) = ast.facets(any) else {
continue;
};
let Some(span) = facets.source.span else {
continue;
};
if !matches!(facets.source.origin, zerodds_ast::SourceOrigin::File { .. }) {
continue;
}
let expanded = parsed.source_span(zerodds_idl_cst::Span::new(span.start, span.end));
if let Some(placed) = preprocessed.provenance(expanded) {
placements.push((any, placed));
}
}
for (any, placed) in placements {
let Some(facets) = ast.facets_mut(any) else {
continue;
};
facets.source.origin = zerodds_ast::SourceOrigin::File { path: placed.path };
facets.source.span = Some(zerodds_ast::ByteSpan {
start: placed.span.start,
end: placed.span.end,
});
facets.source.macro_chain = placed
.macro_chain
.into_iter()
.map(|used| zerodds_ast::MacroExpansion {
name: used.name,
invoked_at: Some(zerodds_ast::ByteSpan {
start: used.at.start,
end: used.at.end,
}),
path: used.path,
})
.collect();
}
}
impl ComposeOptions {
#[must_use]
pub fn lowering(&self) -> zerodds_idl_lower::Options {
zerodds_idl_lower::Options {
default_extensibility: self.default_extensibility,
default_nested: self.default_nested,
hashed_id_advances_the_counter: self.hashed_id_advances_the_counter,
hashed_name_spelling: self.hashed_name_spelling,
key_on_union_discriminator: self.key_on_union_discriminator,
name_of_the_enclosing_scope: self.name_of_the_enclosing_scope,
dialect: self.dialect,
}
}
}
fn inadmissible_cycles(ast: &Ast) -> Vec<Diagnostic> {
let key = zerodds_ast_layout::LayoutKey::data(
zerodds_ast_layout::Encoding::PlainCdr2,
zerodds_ast_layout::Endianness::Little,
);
let closing = zerodds_ast_layout::types_that_close_an_inadmissible_cycle(ast, key);
let declared: Vec<_> = closing
.iter()
.copied()
.filter(|(id, _)| {
ast.type_(*id)
.is_ok_and(|node| node.facets.source.span.is_some())
})
.collect();
let reported = if declared.is_empty() {
closing
} else {
declared
};
reported
.into_iter()
.map(|(id, node_id)| {
let name = ast
.scoped_name_of(zerodds_ast::AnyId::Type(id, node_id))
.map_or_else(|| String::from("a type"), |scoped| scoped.to_string());
let span = ast
.type_(id)
.ok()
.and_then(|node| node.facets.source.span)
.map_or_else(
|| zerodds_idl_cst::Span::new(0, 0),
|span| zerodds_idl_cst::Span::new(span.start, span.end),
);
Diagnostic::error(
zerodds_idl_parse::DiagnosticCode::InvalidBound,
span,
format!(
"`{name}` closes a type cycle with nothing on it that terminates a value \
— no collection, @optional member or productive @external indirection — \
which OMG IDL 4.2 §7.4.1.4.4.4.4 and XTypes recursion rules do not admit"
),
"OMG IDL 4.2 §7.4.1.4.4.4.4",
)
})
.collect()
}
fn resolve(ast: &mut Ast, path: &str) -> Vec<Diagnostic> {
let Ok(report) = ast.resolve_all_by_name() else {
return vec![Diagnostic {
severity: Severity::Error,
code: zerodds_idl_parse::DiagnosticCode::UnresolvedName,
message: format!("{path}: the model could not run a resolve pass"),
primary: zerodds_idl_cst::Span::new(0, 0),
labels: Vec::new(),
file: None,
spec_reference: "OMG IDL 4.2 §7.5",
}];
};
report
.broken
.iter()
.map(|(node, cause)| {
let facets = ast.resolve_node_id(*node).and_then(|any| ast.facets(any));
let primary = facets
.and_then(|f| f.source.span)
.map_or(zerodds_idl_cst::Span::new(0, 0), |span| {
zerodds_idl_cst::Span::new(span.start, span.end)
});
let file = facets.and_then(|f| match &f.source.origin {
zerodds_ast::SourceOrigin::File { path } => Some(path.clone()),
_ => None,
});
Diagnostic {
severity: Severity::Error,
code: zerodds_idl_parse::DiagnosticCode::UnresolvedName,
message: format!("a name does not resolve: {cause:?} (node {node:?})"),
primary,
labels: Vec::new(),
file,
spec_reference: "OMG IDL 4.2 §7.5",
}
})
.collect()
}
pub fn compose_all(
paths: &[impl AsRef<std::path::Path>],
loader: &dyn FileLoader,
options: ComposeOptions,
) -> Result<Composed, String> {
let mut sources = Vec::with_capacity(paths.len());
for path in paths {
let path = path.as_ref();
let name = path.to_string_lossy().into_owned();
let text = std::fs::read_to_string(path).map_err(|error| format!("{name}: {error}"))?;
sources.push((name, text));
}
Ok(compose_all_sources(&sources, loader, options))
}
#[must_use]
pub fn compose_all_sources(
sources: &[(String, String)],
loader: &dyn FileLoader,
options: ComposeOptions,
) -> Composed {
let mut ast = Ast::new();
let mut diagnostics = Vec::new();
for (name, source) in sources {
let preprocessed =
zerodds_idl_parse::preprocess_with(name, source, loader, &options.defines);
let parsed = zerodds_idl_parse::parse_preprocessed(&preprocessed);
let mut unit = parsed.diagnostics().to_vec();
let expanded_from = preprocessed.diagnostics.len();
let tree_from = unit.len();
let before: std::collections::HashMap<zerodds_ast::AnyId, zerodds_ast::SourceFacet> = ast
.all_nodes()
.filter_map(|any| ast.facets(any).map(|facets| (any, facets.source.clone())))
.collect();
unit.extend(zerodds_idl_lower::lower_into_with(
&mut ast,
&parsed.syntax(),
name,
options.lowering(),
));
let lowered_here = |any: zerodds_ast::AnyId| match before.get(&any) {
None => true,
Some(source) => ast_source(&ast, any).is_some_and(|now| now != source),
};
let here: std::collections::HashSet<zerodds_ast::AnyId> =
ast.all_nodes().filter(|any| lowered_here(*any)).collect();
place_nodes_in_their_files(&mut ast, &parsed, &preprocessed, |any| here.contains(&any));
for (index, diagnostic) in unit.iter_mut().enumerate() {
if index >= tree_from {
on_the_expanded_text(&parsed, diagnostic);
}
if index >= expanded_from {
preprocessed.locate(diagnostic);
}
if diagnostic.file.is_none() {
diagnostic.file = Some(name.clone());
}
}
diagnostics.extend(unit);
}
let path = sources
.first()
.map_or_else(|| String::from("<bundle>"), |(name, _)| name.clone());
diagnostics.extend(resolve(&mut ast, &path));
let mut registry = TypeRegistry::new();
let without_type_objects = zerodds_types::from_ast::register_all(&ast, &mut registry)
.map(|skipped| {
skipped
.into_iter()
.map(|(name, error)| (name, format!("{error:?}")))
.collect()
})
.unwrap_or_default();
Composed {
ast,
registry,
diagnostics,
without_type_objects,
expanded: None,
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
use super::*;
use zerodds_idl_parse::MemoryLoader;
#[test]
fn a_clean_unit_composes_and_registers_its_types() {
let composed = compose_source(
"test.idl",
"module m { @appendable struct S { @key long id; }; };",
&MemoryLoader::new(),
);
assert!(!composed.failed(), "{:?}", composed.diagnostics);
assert!(
composed.ast.types().any(|(_, node)| node.name == "S"),
"the type is in the model"
);
assert!(
composed.without_type_objects.is_empty(),
"{:?}",
composed.without_type_objects
);
}
#[test]
fn a_broken_declaration_does_not_discard_the_good_ones() {
let composed = compose_source(
"test.idl",
"module m { struct Good { long a; }; struct Bad { long ; }; };",
&MemoryLoader::new(),
);
assert!(composed.failed(), "the bad one is reported");
assert!(
composed.ast.types().any(|(_, node)| node.name == "Good"),
"the good one survived"
);
}
#[test]
fn a_quoted_include_prefers_the_including_files_directory() {
let dir = std::env::temp_dir().join(format!("idl-compose-{}", std::process::id()));
let beside = dir.join("beside");
let elsewhere = dir.join("elsewhere");
std::fs::create_dir_all(&beside).expect("beside");
std::fs::create_dir_all(&elsewhere).expect("elsewhere");
std::fs::write(beside.join("common.idl"), "struct Near { long a; };").expect("near");
std::fs::write(elsewhere.join("common.idl"), "struct Far { long a; };").expect("far");
let main = beside.join("main.idl");
std::fs::write(&main, "#include \"common.idl\"\nstruct Main { long b; };").expect("main");
let loader = PathLoader::new(vec![elsewhere.clone()]);
let composed = compose(main.to_str().expect("utf8"), &loader).expect("read");
assert!(
composed.ast.types().any(|(_, node)| node.name == "Near"),
"the include beside the file wins"
);
assert!(
!composed.ast.types().any(|(_, node)| node.name == "Far"),
"the one on the search path must not"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_default_only_reaches_types_the_source_left_open() {
use zerodds_ast::Extensibility;
let source = "module m { @final struct Said { long a; }; struct Silent { long a; }; };";
let composed = compose_source_with(
"test.idl",
source,
&MemoryLoader::new(),
ComposeOptions {
default_extensibility: Some(Extensibility::Mutable),
..ComposeOptions::default()
},
);
let of = |name: &str| {
let (id, _) = composed
.ast
.types()
.find(|(_, node)| node.name == name)
.expect("the type");
composed.ast.effective_extensibility(id)
};
assert_eq!(of("Said"), Extensibility::Final, "the source said final");
assert_eq!(of("Silent"), Extensibility::Mutable, "the option filled it");
}
#[test]
fn without_the_option_the_specification_default_applies() {
use zerodds_ast::Extensibility;
let composed = compose_source(
"test.idl",
"module m { struct Silent { long a; }; };",
&MemoryLoader::new(),
);
let (id, _) = composed
.ast
.types()
.find(|(_, node)| node.name == "Silent")
.expect("the type");
assert_eq!(
composed.ast.effective_extensibility(id),
Extensibility::Appendable,
"XTypes 1.3 §7.3.1.2.1.8"
);
}
#[test]
fn a_define_is_in_place_before_the_first_line() {
let composed = compose_source_with(
"test.idl",
"#ifdef WITH_EXTRA\nstruct Extra { long a; };\n#endif\nstruct Always { long b; };",
&MemoryLoader::new(),
ComposeOptions {
defines: vec![(String::from("WITH_EXTRA"), String::new())],
..ComposeOptions::default()
},
);
assert!(
composed.ast.types().any(|(_, node)| node.name == "Extra"),
"the guarded declaration is in"
);
let without = compose_source(
"test.idl",
"#ifdef WITH_EXTRA\nstruct Extra { long a; };\n#endif\nstruct Always { long b; };",
&MemoryLoader::new(),
);
assert!(
!without.ast.types().any(|(_, node)| node.name == "Extra"),
"and out without the define"
);
}
#[test]
fn the_loader_records_every_file_it_opened() {
let dir = std::env::temp_dir().join(format!("idl-compose-deps-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("dir");
std::fs::write(dir.join("common.idl"), "struct Shared { long a; };").expect("common");
let main = dir.join("main.idl");
std::fs::write(&main, "#include \"common.idl\"\nstruct Main { long b; };").expect("main");
let loader = PathLoader::bare();
let _ = compose(main.to_str().expect("utf8"), &loader).expect("read");
let opened = loader.opened();
assert!(
opened.iter().any(|p| p.ends_with("common.idl")),
"the include is in the list: {opened:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_missing_file_is_an_error_and_names_itself() {
let Err(error) = compose("/nonexistent/nope.idl", &PathLoader::bare()) else {
panic!("a path that does not exist must not compose")
};
assert!(error.contains("nope.idl"), "{error}");
}
}