blob: 908d3b9e65a5bb4e3255430a64660b217eba4c9d [file] [log] [blame]
// Copyright 2026 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
use crate::Error;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::types::document::{DocumentContext, parse_and_hydrate};
pub trait FileResolver {
/// Returns the absolute path found and the file content
fn resolve(&self, path: &Path, current_dir: &Path) -> Result<(PathBuf, String), Error>;
}
pub struct OsResolver {
include_paths: Vec<PathBuf>,
}
impl OsResolver {
pub fn new(include_paths: Vec<PathBuf>) -> Self {
let canonical_paths =
include_paths.into_iter().map(|p| std::fs::canonicalize(&p).unwrap_or(p)).collect();
Self { include_paths: canonical_paths }
}
}
impl FileResolver for OsResolver {
fn resolve(&self, path: &Path, current_dir: &Path) -> Result<(PathBuf, String), Error> {
let local_path =
if path.is_absolute() { path.to_path_buf() } else { current_dir.join(path) };
let include_paths_iter = self.include_paths.iter().map(|dir| dir.join(path));
let first_existing_path = std::iter::once(local_path)
.chain(include_paths_iter)
.find(|p| p.exists())
.ok_or_else(|| Error::internal(format!("File not found: {:?}", path)))?;
let abs = fs::canonicalize(&first_existing_path).map_err(|e| Error::Io(e))?;
let content = std::fs::read_to_string(&abs).map_err(|e| Error::Io(e))?;
Ok((abs, content))
}
}
/// The invoker of CmlLoader is responsible for writing their own depfile.
pub struct CmlLoader<R: FileResolver> {
resolver: R,
visited: HashSet<PathBuf>,
}
impl<R: FileResolver> CmlLoader<R> {
pub fn new(resolver: R) -> Self {
Self { resolver, visited: HashSet::new() }
}
pub fn load_and_merge_all(&mut self, root_path: &Path) -> Result<DocumentContext, Error> {
let (root_path_abs, buffer) =
self.resolver.resolve(root_path, Path::new(".")).map_err(|e| {
Error::parse(
format!("Could not resolve root file {}: {}", root_path.display(), e),
None,
None,
)
})?;
self.visited.insert(root_path_abs.clone());
let file_arc = Arc::new(root_path_abs.clone());
let mut root_doc = parse_and_hydrate(file_arc, &buffer)?;
let mut stack = HashSet::new();
stack.insert(root_path_abs.clone());
self.resolve_includes_recursive(&mut root_doc, &root_path_abs, &mut stack)?;
Ok(root_doc)
}
fn resolve_includes_recursive(
&mut self,
target_doc: &mut DocumentContext,
current_file_path: &Path,
stack: &mut HashSet<PathBuf>,
) -> Result<(), Error> {
let current_dir = current_file_path.parent().unwrap_or_else(|| Path::new("."));
if let Some(includes) = target_doc.include.take() {
for include_span in includes {
let include_path = Path::new(&include_span.value);
let (shard_path_abs, buffer) = self
.resolver
.resolve(include_path, current_dir)
.map_err(|e| e.with_origin(include_span.origin.clone()))?;
if stack.contains(&shard_path_abs) {
return Err(Error::validate_context(
format!("Circular include detected: {:?}", shard_path_abs),
Some(include_span.origin.clone()),
));
}
if self.visited.contains(&shard_path_abs) {
continue;
}
self.visited.insert(shard_path_abs.clone());
stack.insert(shard_path_abs.clone());
let file_arc = Arc::new(shard_path_abs.clone());
let mut shard_doc = parse_and_hydrate(file_arc, &buffer)
.map_err(|e| e.with_origin(include_span.origin.clone()))?;
let result =
self.resolve_includes_recursive(&mut shard_doc, &shard_path_abs, stack);
stack.remove(&shard_path_abs);
result?;
target_doc.merge_from(shard_doc, &shard_path_abs)?;
}
}
Ok(())
}
pub fn visited_files(&self) -> HashSet<PathBuf> {
self.visited.clone()
}
}
#[cfg(test)]
mod tests {
use crate::OneOrMany;
use super::*;
use serde_json::json;
use std::collections::HashMap;
pub struct MockResolver {
pub files: HashMap<PathBuf, String>,
}
impl FileResolver for MockResolver {
fn resolve(&self, path: &Path, current_dir: &Path) -> Result<(PathBuf, String), Error> {
let relative = current_dir.join(path);
if let Some(content) = self.files.get(&relative) {
return Ok((relative, content.clone()));
}
if let Some(content) = self.files.get(path) {
return Ok((path.to_path_buf(), content.clone()));
}
Err(Error::internal(format!(
"Mock file not found: {:?}. (Current dir: {:?})",
path, current_dir
)))
}
}
#[test]
fn test_include_empty_array() {
let mut files = HashMap::new();
let main_path = PathBuf::from("main.cml");
files.insert(
main_path.clone(),
r#"{ "include": [], "program": { "runner": "elf" } }"#.to_string(),
);
let resolver = MockResolver { files };
let mut loader = CmlLoader::new(resolver);
let doc =
loader.load_and_merge_all(&main_path).expect("Failed to handle empty include array");
assert!(doc.program.is_some());
}
#[test]
fn test_no_includes() {
let mut files = HashMap::new();
let main_path = PathBuf::from("main.cml");
files.insert(main_path.clone(), r#"{ "program": { "runner": "elf" } }"#.to_string());
let resolver = MockResolver { files };
let mut loader = CmlLoader::new(resolver);
let doc = loader
.load_and_merge_all(&main_path)
.expect("Failed to load document without includes");
assert!(doc.program.is_some());
}
#[test]
fn test_recursive_merge() {
let mut files = HashMap::new();
let root_path = PathBuf::from("/app/main.cml");
let shard_path = PathBuf::from("/app/shards/network.shard.cml");
files.insert(
root_path.clone(),
r#"{ "include": [ "shards/network.shard.cml" ] }"#.to_string(),
);
files.insert(
shard_path.clone(),
r#"{ "capabilities": [ { "protocol": "fuchsia.test.Protocol" } ] }"#.to_string(),
);
let resolver = MockResolver { files };
let mut loader = CmlLoader::new(resolver);
let doc = loader.load_and_merge_all(&root_path).expect("Mock load failed");
let caps = doc.capabilities.as_ref().unwrap();
let protocol_field = caps[0].value.protocol.as_ref().expect("Protocol missing");
match &protocol_field.value {
OneOrMany::One(name) => {
assert_eq!(name.as_str(), "fuchsia.test.Protocol");
}
OneOrMany::Many(_) => {
panic!("Expected a single protocol, found a list");
}
}
assert_eq!(caps[0].origin.as_ref(), &shard_path);
}
#[test]
fn test_invalid_include_file_not_found() {
let mut files = HashMap::new();
let main_path = PathBuf::from("main.cml");
files.insert(main_path.clone(), r#"{ "include": ["doesnt_exist.cml"] }"#.to_string());
let resolver = MockResolver { files };
let mut loader = CmlLoader::new(resolver);
let result = loader.load_and_merge_all(&main_path);
assert!(result.is_err(), "Loader should fail when an include is missing");
}
#[test]
fn test_relative_include_chain() {
let mut files = HashMap::new();
let root_path = PathBuf::from("/root.cml");
let driver_path = PathBuf::from("/sys/driver.cml");
let logger_path = PathBuf::from("/sys/logger.cml");
files.insert(root_path.clone(), r#"{ "include": [ "sys/driver.cml" ] }"#.to_string());
// driver.cml includes "logger.cml" relative to itself (/sys)
files.insert(driver_path.clone(), r#"{ "include": [ "logger.cml" ] }"#.to_string());
files.insert(
logger_path.clone(),
r#"{
"capabilities": [ { "protocol": "fuchsia.sys.Logger" } ]
}"#
.to_string(),
);
let resolver = MockResolver { files };
let mut loader = CmlLoader::new(resolver);
let doc = loader.load_and_merge_all(&root_path).expect("Failed to follow relative chain");
let caps = doc.capabilities.as_ref().unwrap();
assert_eq!(caps.len(), 1);
assert_eq!(caps[0].origin.as_ref(), &logger_path);
}
#[test]
fn test_circular_include_error() {
let mut files = HashMap::new();
let a_path = PathBuf::from("/a.cml");
let b_path = PathBuf::from("/b.cml");
files.insert(a_path.clone(), r#"{ "include": [ "b.cml" ] }"#.to_string());
files.insert(b_path.clone(), r#"{ "include": [ "a.cml" ] }"#.to_string());
let resolver = MockResolver { files };
let mut loader = CmlLoader::new(resolver);
let result = loader.load_and_merge_all(&a_path);
assert!(result.is_err());
}
#[test]
fn test_include_from_search_path() {
let mut files = HashMap::new();
let root_path = PathBuf::from("/app/root.cml");
let sdk_path = PathBuf::from("/sdk/lib/common.cml");
files.insert(root_path.clone(), r#"{ "include": [ "common.cml" ] }"#.to_string());
files.insert(sdk_path.clone(), r#"{ "offer": [] }"#.to_string());
struct SearchPathMock {
files: HashMap<PathBuf, String>,
}
impl FileResolver for SearchPathMock {
fn resolve(&self, path: &Path, current_dir: &Path) -> Result<(PathBuf, String), Error> {
let local = current_dir.join(path);
if self.files.contains_key(&local.clone()) {
return Ok((local.clone(), self.files[&local].clone()));
}
let sdk_candidate = PathBuf::from("/sdk/lib").join(path);
if let Some(content) = self.files.get(&sdk_candidate) {
return Ok((sdk_candidate, content.clone()));
}
Err(Error::internal("Not found"))
}
}
let resolver = SearchPathMock { files };
let mut loader = CmlLoader::new(resolver);
let result = loader.load_and_merge_all(&root_path);
assert!(result.is_ok(), "Loader should have found common.cml in the SDK path");
}
#[test]
fn test_include_cml_with_dictionary() {
let mut files = HashMap::new();
let shard_path = PathBuf::from("shard.cml");
let main_path = PathBuf::from("main.cml");
files.insert(
shard_path.clone(),
json!({
"expose": [
{
"dictionary": "diagnostics",
"from": "self",
}
],
"capabilities": [
{
"dictionary": "diagnostics",
}
]
})
.to_string(),
);
files.insert(
main_path.clone(),
json!({
"include": ["shard.cml"],
"program": {
"binary": "bin/hello_world",
"runner": "foo"
}
})
.to_string(),
);
let resolver = MockResolver { files };
let mut loader = CmlLoader::new(resolver);
let merged_doc = loader.load_and_merge_all(&main_path).unwrap();
let expected_cml = json!({
"program": {
"binary": "bin/hello_world",
"runner": "foo"
},
"expose": [
{
"dictionary": "diagnostics",
"from": "self",
}
],
"capabilities": [
{
"dictionary": "diagnostics",
}
]
})
.to_string();
let expected_doc = crate::load_cml_with_context(&expected_cml, Path::new("expected.cml"))
.expect("failed to parse expected");
assert_eq!(merged_doc, expected_doc)
}
#[test]
fn test_diamond_dependency_is_safe() {
let mut files = HashMap::new();
let a = PathBuf::from("/a.cml");
let b = PathBuf::from("/b.cml");
let c = PathBuf::from("/c.cml");
let d = PathBuf::from("/d.cml");
files.insert(a.clone(), r#"{ "include": ["b.cml", "c.cml"] }"#.to_string());
files.insert(b.clone(), r#"{ "include": ["d.cml"] }"#.to_string());
files.insert(c.clone(), r#"{ "include": ["d.cml"] }"#.to_string());
files.insert(
d.clone(),
r#"{
"capabilities": [ { "protocol": "fuchsia.diamond.Protocol" } ]
}"#
.to_string(),
);
let resolver = MockResolver { files };
let mut loader = CmlLoader::new(resolver);
let doc = loader.load_and_merge_all(&a).expect("Diamond dependency should succeed");
let caps = doc.capabilities.as_ref().unwrap();
assert_eq!(caps.len(), 1, "Should have exactly one capability after merge");
assert!(loader.visited_files().contains(&d));
}
#[test]
fn test_include_multiple_shards() {
let mut files = HashMap::new();
let main_path = PathBuf::from("main.cml");
files.insert(
main_path.clone(),
r#"{ "include": ["shard1.cml", "shard2.cml"] }"#.to_string(),
);
files.insert(
PathBuf::from("shard1.cml"),
r#"{ "use": [{ "protocol": "fuchsia.foo.A" }] }"#.to_string(),
);
files.insert(
PathBuf::from("shard2.cml"),
r#"{ "use": [{ "protocol": "fuchsia.foo.B" }] }"#.to_string(),
);
let resolver = MockResolver { files };
let mut loader = CmlLoader::new(resolver);
let doc = loader.load_and_merge_all(&main_path).expect("Failed to load multiple shards");
let uses = doc.r#use.expect("Should have merged use block");
assert_eq!(uses.len(), 2, "Should have loaded and merged both shards");
}
#[test]
fn test_include_absolute_path() {
let mut files = HashMap::new();
let main_path = PathBuf::from("/some/nested/dir/main.cml");
files.insert(main_path.clone(), r#"{ "include": ["//path/to/shard.cml"] }"#.to_string());
// The mock file exists at the absolute root, not relative to main.cml
files.insert(
PathBuf::from("/path/to/shard.cml"),
r#"{ "capabilities": [{ "protocol": "foo" }] }"#.to_string(),
);
let resolver = MockResolver { files };
let mut loader = CmlLoader::new(resolver);
let doc =
loader.load_and_merge_all(&main_path).expect("Failed to resolve absolute include");
assert!(doc.capabilities.is_some());
}
}