microcad_lang/resolve/
externals.rs

1// Copyright © 2025 The µcad authors <info@ucad.xyz>
2// SPDX-License-Identifier: AGPL-3.0-or-later
3
4//! External files register
5
6use crate::{resolve::*, syntax::*, MICROCAD_EXTENSIONS};
7use derive_more::Deref;
8
9/// External files register.
10///
11/// A map of *qualified name* -> *source file path* which is generated at creation
12/// by scanning in the given `search_paths`.
13#[derive(Default, Deref)]
14pub struct Externals(std::collections::HashMap<QualifiedName, std::path::PathBuf>);
15
16impl Externals {
17    /// Creates externals list.
18    ///
19    /// Recursively scans given `search_paths` for µcad files but files will not be loaded.
20    /// # Arguments
21    /// - `search_paths`: Paths to search for any external files.
22    pub fn new(search_paths: &[impl AsRef<std::path::Path>]) -> ResolveResult<Self> {
23        if search_paths.is_empty() {
24            log::info!("No external search paths were given");
25            Ok(Externals::default())
26        } else {
27            let new = Self(Self::search_externals(search_paths)?);
28            if new.is_empty() {
29                log::warn!("Did not find any externals in any search path");
30            } else {
31                log::info!("Found {} external module(s): {new}", new.len());
32                log::trace!("Externals:\n{new:?}");
33            }
34            Ok(new)
35        }
36    }
37
38    /// Search for an external file which may include a given qualified name.
39    ///
40    /// # Arguments
41    /// - `name`: Qualified name expected to find.
42    pub fn fetch_external(
43        &self,
44        name: &QualifiedName,
45    ) -> ResolveResult<(QualifiedName, std::path::PathBuf)> {
46        log::trace!("fetching {name} from externals");
47
48        if let Some(found) = self
49            .0
50            .iter()
51            // filter all files which might include name
52            .filter(|(n, _)| name.is_within(n))
53            // find the file which has the longest name match
54            .max_by_key(|(name, _)| name.len())
55            // clone the references
56            .map(|(name, path)| ((*name).clone(), (*path).clone()))
57        {
58            return Ok(found);
59        }
60
61        Err(ResolveError::ExternalSymbolNotFound(name.clone()))
62    }
63
64    /// Get qualified name by path
65    pub fn get_name(&self, path: &std::path::Path) -> ResolveResult<&QualifiedName> {
66        match self.0.iter().find(|(_, p)| p.as_path() == path) {
67            Some((name, _)) => {
68                log::trace!("got name of {path:?} => {name}");
69                Ok(name)
70            }
71            None => Err(ResolveError::ExternalPathNotFound(path.to_path_buf())),
72        }
73    }
74
75    /// Searches for external source code files (*external modules*) in given *search paths*.
76    fn search_externals(
77        search_paths: &[impl AsRef<std::path::Path>],
78    ) -> ResolveResult<std::collections::HashMap<QualifiedName, std::path::PathBuf>> {
79        search_paths
80            .iter()
81            .inspect(|p| log::trace!("Searching externals in: {:?}", p.as_ref()))
82            .map(|search_path| {
83                scan_dir::ScanDir::all()
84                    .read(search_path.as_ref(), |iter| {
85                        iter.map(|(entry, _)| entry.path())
86                            .map(find_external_mod)
87                            // catch eny errors here
88                            .collect::<Result<Vec<_>, _>>()?
89                            .into_iter()
90                            .flatten()
91                            .map(|file| {
92                                let name = make_symbol_name(
93                                    file.strip_prefix(search_path)
94                                        .expect("cannot strip search path from file name"),
95                                );
96                                let path = file.canonicalize().expect("path not found");
97                                log::trace!("Found external: {name} {path:?}");
98                                Ok((name, path))
99                            })
100                            .collect::<Result<Vec<_>, _>>()
101                    })
102                    .into_iter()
103                    .collect::<Result<Vec<_>, _>>()
104                    .map(|v| v.into_iter().flatten())
105            })
106            .collect::<Result<Vec<_>, _>>()
107            .map(|v| v.into_iter().flatten().collect())
108    }
109}
110
111impl std::fmt::Display for Externals {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        let mut v = self.0.iter().collect::<Vec<_>>();
114        // sort for better readability
115        v.sort();
116        write!(
117            f,
118            "{}",
119            v.iter()
120                .map(|file| file.0.to_string())
121                .collect::<Vec<_>>()
122                .join(", ")
123        )
124    }
125}
126
127impl std::fmt::Debug for Externals {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        let mut v = self.0.iter().collect::<Vec<_>>();
130        // sort for better readability
131        v.sort();
132        v.iter()
133            .try_for_each(|file| writeln!(f, "{} => {}", file.0, file.1.to_string_lossy()))
134    }
135}
136
137fn make_symbol_name(relative_path: impl AsRef<std::path::Path>) -> QualifiedName {
138    let path = relative_path.as_ref();
139    let stem = path.file_stem().map(|s| s.to_string_lossy().to_string());
140    let name = if stem == Some("mod".into()) {
141        path.parent().expect("mod file without parent folder")
142    } else {
143        path
144    };
145    name.iter()
146        .map(|id| Identifier::no_ref(id.to_string_lossy().as_ref()))
147        .collect()
148}
149
150fn search_mod_dir_file(
151    path: impl AsRef<std::path::Path>,
152) -> ResolveResult<Option<std::path::PathBuf>> {
153    log::trace!("search_mod_dir_file: {:?}", path.as_ref());
154    let files = scan_dir::ScanDir::files().read(path, |iter| {
155        iter.map(|(ref entry, _)| entry.path())
156            .filter(is_mod_file)
157            .collect::<Vec<_>>()
158    })?;
159    if let Some(file) = files.first() {
160        match files.len() {
161            1 => Ok(Some(file.clone())),
162            _ => Err(ResolveError::AmbiguousExternals(files)),
163        }
164    } else {
165        Ok(None)
166    }
167}
168
169/// Return `true` if given path has a valid microcad extension
170#[allow(clippy::ptr_arg)]
171fn is_microcad_file(p: &std::path::PathBuf) -> bool {
172    p.is_file()
173        && p.extension()
174            .map(|ext| {
175                MICROCAD_EXTENSIONS
176                    .iter()
177                    .any(|extension| *extension == ext)
178            })
179            .unwrap_or(false)
180}
181
182/// Return `true` if given path is a file called `mod` plus a valid microcad extension
183fn is_mod_file(p: &std::path::PathBuf) -> bool {
184    is_microcad_file(p)
185        && p.file_stem()
186            .and_then(|s| s.to_str())
187            .is_some_and(|s| s == "mod")
188}
189
190/// Find a module file by path and id.
191///
192/// Module files might be on of the following:
193///
194/// - \<path>`/`\<id>`.`*ext*
195/// - \<path>`/`\<id>`/mod.`*ext*
196///
197/// *ext* = any valid microcad file extension.
198pub fn find_mod_file_by_id(
199    path: impl AsRef<std::path::Path>,
200    id: &Identifier,
201) -> ResolveResult<std::path::PathBuf> {
202    let path = path.as_ref();
203    log::trace!("find_mod_file_by_id: {path:?} {id:?}");
204    match (
205        search_mod_file_by_id(path, id),
206        search_mod_dir_file(path.join(id.to_string())),
207    ) {
208        (Ok(file), Ok(Some(dir))) => Err(ResolveError::AmbiguousExternals(vec![file, dir])),
209        (Ok(file), Err(_) | Ok(None)) | (Err(_), Ok(Some(file))) => Ok(file),
210        (Err(err), _) => Err(err),
211    }
212}
213
214fn find_external_mod(
215    path: impl AsRef<std::path::Path>,
216) -> ResolveResult<Option<std::path::PathBuf>> {
217    log::trace!("find mod file ex: {:?}", path.as_ref());
218    let path = path.as_ref().to_path_buf();
219    if path.is_dir() {
220        return search_mod_dir_file(&path);
221    }
222    if is_microcad_file(&path) {
223        Ok(Some(path))
224    } else {
225        Ok(None)
226    }
227}
228
229fn search_mod_file_by_id(
230    path: impl AsRef<std::path::Path>,
231    id: &Identifier,
232) -> ResolveResult<std::path::PathBuf> {
233    let path = path.as_ref();
234
235    // Patch path if we are in a test environment
236    let path = if std::fs::exists(path.join(".test")).expect("file access failure") {
237        path.join(".test")
238    } else {
239        path.into()
240    };
241
242    log::trace!("search_mod_file_by_id: {path:?} {id}");
243    if let Some(path) = scan_dir::ScanDir::files().read(&path, |iter| {
244        iter.map(|(entry, _)| entry.path())
245            .filter(is_microcad_file)
246            .find(|p| {
247                p.file_stem()
248                    .map(|stem| *stem == *id.to_string())
249                    .unwrap_or(false)
250            })
251    })? {
252        Ok(path)
253    } else {
254        Err(ResolveError::SourceFileNotFound(
255            id.clone(),
256            path.to_path_buf(),
257        ))
258    }
259}
260
261#[test]
262fn resolve_external_file() {
263    let externals = Externals::new(&["../lib"]).expect("test error");
264
265    assert!(!externals.is_empty());
266
267    log::trace!("{externals}");
268
269    assert!(externals
270        .fetch_external(&"std::geo2d::Circle".into())
271        .is_ok());
272
273    assert!(externals
274        .fetch_external(&"non_std::geo2d::Circle".into())
275        .is_err());
276}