1use crate::Error;
6use std::collections::HashSet;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9
10use crate::types::document::{DocumentContext, parse_and_hydrate};
11
12pub trait FileResolver {
13 fn resolve(&self, path: &Path, current_dir: &Path) -> Result<(PathBuf, String), Error>;
15}
16
17pub struct OsResolver {
18 include_paths: Vec<PathBuf>,
19 include_root: PathBuf,
20}
21
22impl OsResolver {
23 pub fn new(include_paths: Vec<PathBuf>, include_root: PathBuf) -> Self {
24 let normalized_paths = include_paths.into_iter().map(|p| normalize_path(&p)).collect();
25 let normalized_root = normalize_path(&include_root);
26
27 Self { include_paths: normalized_paths, include_root: normalized_root }
28 }
29}
30
31impl FileResolver for OsResolver {
32 fn resolve(&self, path: &Path, current_dir: &Path) -> Result<(PathBuf, String), Error> {
33 let path_str = path.to_str().unwrap_or("");
34
35 let clean_path = if path_str.starts_with("//") {
36 let p = self.include_root.join(&path_str[2..]);
37 let norm = normalize_path(&p);
38 if norm.exists() { Some(norm) } else { None }
39 } else if path.is_absolute() {
40 let norm = normalize_path(path);
41 if norm.exists() { Some(norm) } else { None }
42 } else {
43 std::iter::once(current_dir.join(path))
44 .chain(std::iter::once(path.to_path_buf()))
45 .chain(self.include_paths.iter().map(|dir| dir.join(path)))
46 .map(|p| normalize_path(&p))
47 .find(|p| p.exists())
48 };
49
50 let clean_path =
51 clean_path.ok_or_else(|| Error::Internal(format!("File not found: {:?}", path)))?;
52
53 let content = std::fs::read_to_string(&clean_path)
54 .map_err(|e| Error::Internal(format!("Read error {:?}: {}", clean_path, e)))?;
55
56 Ok((clean_path, content))
57 }
58}
59
60pub struct CmlLoader<R: FileResolver> {
62 resolver: R,
63 visited: HashSet<PathBuf>,
64}
65
66impl<R: FileResolver> CmlLoader<R> {
67 pub fn new(resolver: R) -> Self {
68 Self { resolver, visited: HashSet::new() }
69 }
70
71 pub fn load_and_merge_all(&mut self, root_path: &Path) -> Result<DocumentContext, Error> {
72 let (root_path_rel, buffer) = self.resolver.resolve(root_path, Path::new(""))?;
73
74 self.visited.insert(root_path_rel.clone());
75 let file_arc = Arc::new(root_path_rel.clone());
76 let mut root_doc = parse_and_hydrate(file_arc, &buffer)?;
77
78 let mut stack = HashSet::new();
79 stack.insert(root_path_rel.clone());
80
81 self.resolve_includes_recursive(&mut root_doc, &root_path_rel, &mut stack)?;
82
83 Ok(root_doc)
84 }
85
86 fn resolve_includes_recursive(
87 &mut self,
88 target_doc: &mut DocumentContext,
89 current_file_path: &Path,
90 stack: &mut HashSet<PathBuf>,
91 ) -> Result<(), Error> {
92 let current_dir = current_file_path.parent().unwrap_or_else(|| Path::new(""));
93
94 if let Some(includes) = target_doc.include.take() {
95 for include_span in includes {
96 let include_path = Path::new(&include_span.value);
97
98 let (shard_path_abs, buffer) = self
99 .resolver
100 .resolve(include_path, ¤t_dir)
101 .map_err(|e| e.with_origin(include_span.origin.clone()))?;
102
103 if stack.contains(&shard_path_abs) {
104 return Err(Error::validate_context(
105 format!("Circular include detected: {:?}", shard_path_abs),
106 Some(include_span.origin.clone()),
107 ));
108 }
109
110 if self.visited.contains(&shard_path_abs) {
111 continue;
112 }
113
114 self.visited.insert(shard_path_abs.clone());
115 stack.insert(shard_path_abs.clone());
116
117 let file_arc = Arc::new(shard_path_abs.clone());
118 let mut shard_doc = parse_and_hydrate(file_arc, &buffer)
119 .map_err(|e| e.with_origin(include_span.origin.clone()))?;
120
121 let result =
122 self.resolve_includes_recursive(&mut shard_doc, &shard_path_abs, stack);
123
124 stack.remove(&shard_path_abs);
125
126 result?;
127
128 target_doc.merge_from(shard_doc, &shard_path_abs)?;
129 }
130 }
131 Ok(())
132 }
133
134 pub fn visited_files(&self) -> HashSet<PathBuf> {
135 self.visited.clone()
136 }
137}
138
139fn normalize_path(path: &Path) -> PathBuf {
141 let mut ret = PathBuf::new();
142
143 for component in path.components() {
144 match component {
145 std::path::Component::CurDir => {} std::path::Component::ParentDir => {
147 match ret.components().next_back() {
148 Some(std::path::Component::Normal(_)) => {
149 ret.pop();
150 }
151 Some(std::path::Component::RootDir) | Some(std::path::Component::Prefix(_)) => {
152 }
154 _ => {
155 ret.push(component);
156 }
157 }
158 }
159 _ => {
160 ret.push(component);
162 }
163 }
164 }
165 ret
166}
167
168#[cfg(test)]
169mod tests {
170 use crate::OneOrMany;
171
172 use super::*;
173 use serde_json::json;
174 use std::collections::HashMap;
175
176 pub struct MockResolver {
177 pub files: HashMap<PathBuf, String>,
178 }
179
180 impl FileResolver for MockResolver {
181 fn resolve(&self, path: &Path, current_dir: &Path) -> Result<(PathBuf, String), Error> {
182 let relative = current_dir.join(path);
183 if let Some(content) = self.files.get(&relative) {
184 return Ok((relative, content.clone()));
185 }
186
187 if let Some(content) = self.files.get(path) {
188 return Ok((path.to_path_buf(), content.clone()));
189 }
190
191 Err(Error::internal(format!(
192 "Mock file not found: {:?}. (Current dir: {:?})",
193 path, current_dir
194 )))
195 }
196 }
197
198 #[test]
199 fn test_include_empty_array() {
200 let mut files = HashMap::new();
201 let main_path = PathBuf::from("main.cml");
202
203 files.insert(
204 main_path.clone(),
205 r#"{ "include": [], "program": { "runner": "elf" } }"#.to_string(),
206 );
207
208 let resolver = MockResolver { files };
209 let mut loader = CmlLoader::new(resolver);
210
211 let doc =
212 loader.load_and_merge_all(&main_path).expect("Failed to handle empty include array");
213 assert!(doc.program.is_some());
214 }
215
216 #[test]
217 fn test_no_includes() {
218 let mut files = HashMap::new();
219 let main_path = PathBuf::from("main.cml");
220
221 files.insert(main_path.clone(), r#"{ "program": { "runner": "elf" } }"#.to_string());
222
223 let resolver = MockResolver { files };
224 let mut loader = CmlLoader::new(resolver);
225
226 let doc = loader
227 .load_and_merge_all(&main_path)
228 .expect("Failed to load document without includes");
229 assert!(doc.program.is_some());
230 }
231
232 #[test]
233 fn test_recursive_merge() {
234 let mut files = HashMap::new();
235
236 let root_path = PathBuf::from("/app/main.cml");
237 let shard_path = PathBuf::from("/app/shards/network.shard.cml");
238
239 files.insert(
240 root_path.clone(),
241 r#"{ "include": [ "shards/network.shard.cml" ] }"#.to_string(),
242 );
243 files.insert(
244 shard_path.clone(),
245 r#"{ "capabilities": [ { "protocol": "fuchsia.test.Protocol" } ] }"#.to_string(),
246 );
247
248 let resolver = MockResolver { files };
249 let mut loader = CmlLoader::new(resolver);
250
251 let doc = loader.load_and_merge_all(&root_path).expect("Mock load failed");
252
253 let caps = doc.capabilities.as_ref().unwrap();
254 let protocol_field = caps[0].value.protocol.as_ref().expect("Protocol missing");
255
256 match &protocol_field.value {
257 OneOrMany::One(name) => {
258 assert_eq!(name.as_str(), "fuchsia.test.Protocol");
259 }
260 OneOrMany::Many(_) => {
261 panic!("Expected a single protocol, found a list");
262 }
263 }
264
265 assert_eq!(caps[0].origin.as_ref(), &shard_path);
266 }
267
268 #[test]
269 fn test_invalid_include_file_not_found() {
270 let mut files = HashMap::new();
271 let main_path = PathBuf::from("main.cml");
272
273 files.insert(main_path.clone(), r#"{ "include": ["doesnt_exist.cml"] }"#.to_string());
274
275 let resolver = MockResolver { files };
276 let mut loader = CmlLoader::new(resolver);
277
278 let result = loader.load_and_merge_all(&main_path);
279
280 assert!(result.is_err(), "Loader should fail when an include is missing");
281 }
282
283 #[test]
284 fn test_relative_include_chain() {
285 let mut files = HashMap::new();
286
287 let root_path = PathBuf::from("/root.cml");
288 let driver_path = PathBuf::from("/sys/driver.cml");
289 let logger_path = PathBuf::from("/sys/logger.cml");
290
291 files.insert(root_path.clone(), r#"{ "include": [ "sys/driver.cml" ] }"#.to_string());
292 files.insert(driver_path.clone(), r#"{ "include": [ "logger.cml" ] }"#.to_string());
294 files.insert(
295 logger_path.clone(),
296 r#"{
297 "capabilities": [ { "protocol": "fuchsia.sys.Logger" } ]
298 }"#
299 .to_string(),
300 );
301
302 let resolver = MockResolver { files };
303 let mut loader = CmlLoader::new(resolver);
304
305 let doc = loader.load_and_merge_all(&root_path).expect("Failed to follow relative chain");
306
307 let caps = doc.capabilities.as_ref().unwrap();
308 assert_eq!(caps.len(), 1);
309
310 assert_eq!(caps[0].origin.as_ref(), &logger_path);
311 }
312
313 #[test]
314 fn test_circular_include_error() {
315 let mut files = HashMap::new();
316 let a_path = PathBuf::from("/a.cml");
317 let b_path = PathBuf::from("/b.cml");
318
319 files.insert(a_path.clone(), r#"{ "include": [ "b.cml" ] }"#.to_string());
320 files.insert(b_path.clone(), r#"{ "include": [ "a.cml" ] }"#.to_string());
321
322 let resolver = MockResolver { files };
323 let mut loader = CmlLoader::new(resolver);
324
325 let result = loader.load_and_merge_all(&a_path);
326
327 assert!(result.is_err());
328 }
329
330 #[test]
331 fn test_include_from_search_path() {
332 let mut files = HashMap::new();
333 let root_path = PathBuf::from("/app/root.cml");
334
335 let sdk_path = PathBuf::from("/sdk/lib/common.cml");
336
337 files.insert(root_path.clone(), r#"{ "include": [ "common.cml" ] }"#.to_string());
338 files.insert(sdk_path.clone(), r#"{ "offer": [] }"#.to_string());
339
340 struct SearchPathMock {
341 files: HashMap<PathBuf, String>,
342 }
343 impl FileResolver for SearchPathMock {
344 fn resolve(&self, path: &Path, current_dir: &Path) -> Result<(PathBuf, String), Error> {
345 let local = current_dir.join(path);
346 if self.files.contains_key(&local.clone()) {
347 return Ok((local.clone(), self.files[&local].clone()));
348 }
349
350 let sdk_candidate = PathBuf::from("/sdk/lib").join(path);
351 if let Some(content) = self.files.get(&sdk_candidate) {
352 return Ok((sdk_candidate, content.clone()));
353 }
354 Err(Error::internal("Not found"))
355 }
356 }
357
358 let resolver = SearchPathMock { files };
359 let mut loader = CmlLoader::new(resolver);
360
361 let result = loader.load_and_merge_all(&root_path);
362 assert!(result.is_ok(), "Loader should have found common.cml in the SDK path");
363 }
364
365 #[test]
366 fn test_include_cml_with_dictionary() {
367 let mut files = HashMap::new();
368 let shard_path = PathBuf::from("shard.cml");
369 let main_path = PathBuf::from("main.cml");
370
371 files.insert(
372 shard_path.clone(),
373 json!({
374 "expose": [
375 {
376 "dictionary": "diagnostics",
377 "from": "self",
378 }
379 ],
380 "capabilities": [
381 {
382 "dictionary": "diagnostics",
383 }
384 ]
385 })
386 .to_string(),
387 );
388 files.insert(
389 main_path.clone(),
390 json!({
391 "include": ["shard.cml"],
392 "program": {
393 "binary": "bin/hello_world",
394 "runner": "foo"
395 }
396 })
397 .to_string(),
398 );
399
400 let resolver = MockResolver { files };
401
402 let mut loader = CmlLoader::new(resolver);
403 let merged_doc = loader.load_and_merge_all(&main_path).unwrap();
404
405 let expected_cml = json!({
406 "program": {
407 "binary": "bin/hello_world",
408 "runner": "foo"
409 },
410 "expose": [
411 {
412 "dictionary": "diagnostics",
413 "from": "self",
414 }
415 ],
416 "capabilities": [
417 {
418 "dictionary": "diagnostics",
419 }
420 ]
421 })
422 .to_string();
423
424 let expected_doc = crate::load_cml_with_context(&expected_cml, Path::new("expected.cml"))
425 .expect("failed to parse expected");
426
427 assert_eq!(merged_doc, expected_doc)
428 }
429
430 #[test]
431 fn test_diamond_dependency_is_safe() {
432 let mut files = HashMap::new();
433
434 let a = PathBuf::from("/a.cml");
435 let b = PathBuf::from("/b.cml");
436 let c = PathBuf::from("/c.cml");
437 let d = PathBuf::from("/d.cml");
438
439 files.insert(a.clone(), r#"{ "include": ["b.cml", "c.cml"] }"#.to_string());
440 files.insert(b.clone(), r#"{ "include": ["d.cml"] }"#.to_string());
441 files.insert(c.clone(), r#"{ "include": ["d.cml"] }"#.to_string());
442
443 files.insert(
444 d.clone(),
445 r#"{
446 "capabilities": [ { "protocol": "fuchsia.diamond.Protocol" } ]
447 }"#
448 .to_string(),
449 );
450
451 let resolver = MockResolver { files };
452 let mut loader = CmlLoader::new(resolver);
453
454 let doc = loader.load_and_merge_all(&a).expect("Diamond dependency should succeed");
455
456 let caps = doc.capabilities.as_ref().unwrap();
457
458 assert_eq!(caps.len(), 1, "Should have exactly one capability after merge");
459
460 assert!(loader.visited_files().contains(&d));
461 }
462
463 #[test]
464 fn test_include_multiple_shards() {
465 let mut files = HashMap::new();
466 let main_path = PathBuf::from("main.cml");
467
468 files.insert(
469 main_path.clone(),
470 r#"{ "include": ["shard1.cml", "shard2.cml"] }"#.to_string(),
471 );
472 files.insert(
473 PathBuf::from("shard1.cml"),
474 r#"{ "use": [{ "protocol": "fuchsia.foo.A" }] }"#.to_string(),
475 );
476 files.insert(
477 PathBuf::from("shard2.cml"),
478 r#"{ "use": [{ "protocol": "fuchsia.foo.B" }] }"#.to_string(),
479 );
480
481 let resolver = MockResolver { files };
482 let mut loader = CmlLoader::new(resolver);
483
484 let doc = loader.load_and_merge_all(&main_path).expect("Failed to load multiple shards");
485
486 let uses = doc.r#use.expect("Should have merged use block");
487 assert_eq!(uses.len(), 2, "Should have loaded and merged both shards");
488 }
489
490 #[test]
491 fn test_include_absolute_path() {
492 let mut files = HashMap::new();
493 let main_path = PathBuf::from("/some/nested/dir/main.cml");
494
495 files.insert(main_path.clone(), r#"{ "include": ["//path/to/shard.cml"] }"#.to_string());
496
497 files.insert(
499 PathBuf::from("/path/to/shard.cml"),
500 r#"{ "capabilities": [{ "protocol": "foo" }] }"#.to_string(),
501 );
502
503 let resolver = MockResolver { files };
504 let mut loader = CmlLoader::new(resolver);
505
506 let doc =
507 loader.load_and_merge_all(&main_path).expect("Failed to resolve absolute include");
508 assert!(doc.capabilities.is_some());
509 }
510
511 #[test]
512 fn test_normalize_path() {
513 assert_eq!(normalize_path(Path::new("a/b/../c")), PathBuf::from("a/c"));
514
515 assert_eq!(
516 normalize_path(Path::new("/fuchsia/out/default/../../sdk/lib")),
517 PathBuf::from("/fuchsia/sdk/lib")
518 );
519
520 assert_eq!(normalize_path(Path::new("./a/./b/./c/.")), PathBuf::from("a/b/c"));
521
522 assert_eq!(normalize_path(Path::new("/../../foo")), PathBuf::from("/foo"));
523
524 let double_slash = normalize_path(Path::new("//sdk/lib/shard.cml"));
525 assert!(double_slash.to_str().unwrap().contains("sdk/lib/shard.cml"));
526
527 assert_eq!(normalize_path(Path::new("/a/b/c/../../d/./e/../f")), PathBuf::from("/a/d/f"));
528 }
529
530 #[test]
531 fn test_normalize_relative_leading_dots() {
532 assert_eq!(
533 normalize_path(Path::new("../../examples/hello.cml")),
534 PathBuf::from("../../examples/hello.cml")
535 );
536
537 assert_eq!(
538 normalize_path(Path::new("../../sdk/lib/../inspect/client.shard.cml")),
539 PathBuf::from("../../sdk/inspect/client.shard.cml")
540 );
541 }
542}