Skip to main content

cml/
load.rs

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