Skip to main content

hara_native/project/
resources.rs

1use super::{declared_namespace, files_in, Project};
2use sha2::{Digest, Sha256};
3use std::collections::BTreeMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::sync::{Arc, Mutex};
7use std::time::UNIX_EPOCH;
8
9#[path = "resources/installed.rs"]
10mod installed;
11
12/// A source-only namespace catalog used by native runtimes.
13///
14/// The catalog resolves conventional namespace paths at the `require`
15/// boundary. It discovers the complete legacy path map only when a requested
16/// namespace cannot be derived from its name, so starting a project never
17/// walks unrelated source families such as `lang.*`.
18#[derive(Debug, Clone, Default)]
19pub struct SourceCatalog {
20    entries: Arc<Mutex<BTreeMap<String, PathBuf>>>,
21    roots: Vec<PathBuf>,
22    excluded_roots: Vec<PathBuf>,
23}
24
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26struct FileStamp {
27    length: u64,
28    modified_seconds: u64,
29    modified_nanos: u32,
30}
31
32fn file_stamp(path: &Path) -> Option<FileStamp> {
33    let metadata = fs::metadata(path).ok()?;
34    let modified = metadata.modified().ok()?.duration_since(UNIX_EPOCH).ok()?;
35    Some(FileStamp {
36        length: metadata.len(),
37        modified_seconds: modified.as_secs(),
38        modified_nanos: modified.subsec_nanos(),
39    })
40}
41
42impl SourceCatalog {
43    /// Completes the legacy map for callers that need package-wide metadata.
44    /// Runtime namespace loading should call `path` instead.
45    pub(crate) fn entries(&self) -> BTreeMap<String, PathBuf> {
46        self.discover_legacy_paths();
47        self.entries
48            .lock()
49            .expect("source catalog cache poisoned")
50            .clone()
51    }
52
53    /// Resolves one namespace on demand. Conventional `foo.bar` namespaces
54    /// use `foo/bar.hal` directly; a one-time legacy scan is reserved for
55    /// paths such as `impl_base.hal` declaring `impl-base`.
56    pub fn path(&self, namespace: &str) -> Option<PathBuf> {
57        if let Some(path) = self
58            .entries
59            .lock()
60            .expect("source catalog cache poisoned")
61            .get(namespace)
62            .cloned()
63        {
64            return Some(path);
65        }
66        if let Some(path) = self.conventional_path(namespace) {
67            self.entries
68                .lock()
69                .expect("source catalog cache poisoned")
70                .insert(namespace.to_owned(), path.clone());
71            return Some(path);
72        }
73        self.discover_legacy_paths();
74        self.entries
75            .lock()
76            .expect("source catalog cache poisoned")
77            .get(namespace)
78            .cloned()
79    }
80
81    pub fn namespaces(&self) -> Vec<String> {
82        self.entries().into_keys().collect()
83    }
84
85    /// Returns a stable fingerprint of the indexed source set. The source
86    /// cache keys individual programs by source bytes as well; this broader
87    /// index fingerprint invalidates programs whose compilation depends on a
88    /// changed sibling namespace configuration without rereading every source
89    /// body during ordinary namespace loading.
90    pub fn fingerprint(&self) -> Result<[u8; 32], String> {
91        let mut digest = Sha256::new();
92        digest.update(b"hara-source-index-v1\0");
93        for (namespace, path) in self.entries() {
94            let stamp = file_stamp(&path)
95                .ok_or_else(|| format!("cannot stat source file {}", path.display()))?;
96            digest.update(namespace.as_bytes());
97            digest.update([0]);
98            digest.update(path.to_string_lossy().as_bytes());
99            digest.update([0]);
100            digest.update(stamp.length.to_le_bytes());
101            digest.update(stamp.modified_seconds.to_le_bytes());
102            digest.update(stamp.modified_nanos.to_le_bytes());
103        }
104        Ok(digest.finalize().into())
105    }
106
107    pub(crate) fn cached_namespaces(&self) -> Vec<String> {
108        self.entries
109            .lock()
110            .expect("source catalog cache poisoned")
111            .keys()
112            .cloned()
113            .collect()
114    }
115
116    fn add_project(&mut self, project: &Project) -> Result<(), String> {
117        let project_root = project
118            .root
119            .canonicalize()
120            .map_err(|error| format!("cannot resolve {}: {error}", project.root.display()))?;
121        for excluded_root in &project.source_excludes {
122            let excluded_root = project.root.join(excluded_root);
123            if !excluded_root.exists() {
124                continue;
125            }
126            let excluded_root = excluded_root.canonicalize().map_err(|error| {
127                format!(
128                    "cannot resolve excluded source root {}: {error}",
129                    excluded_root.display()
130                )
131            })?;
132            if !excluded_root.starts_with(&project_root) {
133                return Err(format!(
134                    "excluded source root escapes project root: {}",
135                    excluded_root.display()
136                ));
137            }
138            self.excluded_roots.push(excluded_root);
139        }
140        for source_root in &project.source_paths {
141            let source_root = project.root.join(source_root);
142            if !source_root.exists() {
143                continue;
144            }
145            let source_root = source_root.canonicalize().map_err(|error| {
146                format!(
147                    "cannot resolve source root {}: {error}",
148                    source_root.display()
149                )
150            })?;
151            if !source_root.starts_with(&project_root) {
152                return Err(format!(
153                    "source root escapes project root: {}",
154                    source_root.display()
155                ));
156            }
157            self.roots.push(source_root);
158        }
159        Ok(())
160    }
161
162    fn excluded(&self, path: &Path) -> bool {
163        self.excluded_roots
164            .iter()
165            .any(|excluded_root| path.starts_with(excluded_root))
166    }
167
168    fn conventional_path(&self, namespace: &str) -> Option<PathBuf> {
169        let segments = namespace.split('.').collect::<Vec<_>>();
170        if segments.is_empty()
171            || segments.iter().any(|segment| {
172                segment.is_empty()
173                    || *segment == ".."
174                    || segment.contains('/')
175                    || segment.contains('\\')
176            })
177        {
178            return None;
179        }
180        for root in self.roots.iter().rev() {
181            let mut candidate = root.to_path_buf();
182            for segment in &segments[..segments.len().saturating_sub(1)] {
183                candidate.push(segment);
184            }
185            candidate.push(format!(
186                "{}.hal",
187                segments.last().expect("non-empty segments")
188            ));
189            let path = candidate.canonicalize().ok()?;
190            if path.starts_with(root) && path.is_file() && !self.excluded(&path) {
191                return Some(path);
192            }
193        }
194        None
195    }
196
197    fn discover_legacy_paths(&self) {
198        let mut discovered = BTreeMap::new();
199        for root in &self.roots {
200            let Ok(paths) = files_in(root, &[PathBuf::from(".")]) else {
201                continue;
202            };
203            for path in paths {
204                let Ok(path) = path.canonicalize() else {
205                    continue;
206                };
207                if !path.starts_with(root) || self.excluded(&path) {
208                    continue;
209                }
210                let Ok(source) = fs::read_to_string(&path) else {
211                    continue;
212                };
213                let Ok(Some(namespace)) = declared_namespace_header(&source) else {
214                    continue;
215                };
216                // Project layers are ordered from dependencies to the active
217                // project, so the later mapping is the intentional overlay.
218                discovered.insert(namespace, path);
219            }
220        }
221        self.entries
222            .lock()
223            .expect("source catalog cache poisoned")
224            .extend(discovered);
225    }
226}
227
228fn declared_namespace_header(source: &str) -> Result<Option<String>, String> {
229    let mut depth = 0;
230    let mut form_start = None;
231    let mut in_comment = false;
232    let mut in_string = false;
233    let mut escaped = false;
234    let mut skip_character = false;
235    for (index, character) in source.char_indices() {
236        if skip_character {
237            skip_character = false;
238            continue;
239        }
240        if in_comment {
241            if character == '\n' {
242                in_comment = false;
243            }
244            continue;
245        }
246        if in_string {
247            if escaped {
248                escaped = false;
249            } else if character == '\\' {
250                escaped = true;
251            } else if character == '"' {
252                in_string = false;
253            }
254            continue;
255        }
256        match character {
257            ';' => in_comment = true,
258            '"' => in_string = true,
259            '\\' => skip_character = true,
260            '(' | '[' | '{' => {
261                if depth == 0 && character == '(' {
262                    form_start = Some(index);
263                }
264                depth += 1;
265            }
266            ')' | ']' | '}' if depth > 0 => {
267                depth -= 1;
268                if depth == 0 {
269                    if let Some(start) = form_start.take() {
270                        let end = index + character.len_utf8();
271                        if let Some(namespace) = declared_namespace(&source[start..end])? {
272                            return Ok(Some(namespace));
273                        }
274                    }
275                }
276            }
277            _ => {}
278        }
279    }
280    Ok(None)
281}
282
283/// Builds a path-backed source catalog for one project and its installed Hara
284/// dependencies.
285pub fn source_catalog(project: &Project) -> Result<SourceCatalog, String> {
286    source_catalog_at(project, &dist_root())
287}
288
289/// Builds a source catalog for one project using packages installed beneath an
290/// explicit distribution root. Embedders use this to keep an isolated package
291/// store from falling back to the user's global installation.
292pub fn source_catalog_at(
293    project: &Project,
294    distribution_root: &Path,
295) -> Result<SourceCatalog, String> {
296    source_catalogs_at(&[project], distribution_root)
297}
298
299/// Builds a path-backed source catalog for several ordered project layers.
300/// Each project contributes its verified installed dependencies first,
301/// followed by its own source paths; later project layers take precedence.
302pub fn source_catalogs(projects: &[&Project]) -> Result<SourceCatalog, String> {
303    source_catalogs_at(projects, &dist_root())
304}
305
306/// Builds a multi-project source catalog using one explicit installed-package
307/// store. This keeps dependency resolution deterministic for isolated hosts,
308/// release verification, and native conformance tests.
309pub(crate) fn source_catalogs_at(
310    projects: &[&Project],
311    distribution_root: &Path,
312) -> Result<SourceCatalog, String> {
313    let mut catalog = SourceCatalog::default();
314    for project in projects {
315        for dependency in installed::resolve(project, distribution_root)? {
316            catalog.add_project(&dependency.project)?;
317        }
318        catalog.add_project(project)?;
319    }
320    Ok(catalog)
321}
322
323/// Returns namespace resources from installed dependencies followed by the
324/// automatically selected native Rust profile of the consuming project.
325pub fn source_resources(project: &Project) -> Result<Vec<(String, String)>, String> {
326    source_resources_at(project, &dist_root())
327}
328
329pub(crate) fn source_resources_at(
330    project: &Project,
331    distribution_root: &Path,
332) -> Result<Vec<(String, String)>, String> {
333    let mut resources = Vec::new();
334    let mut declarations = BTreeMap::<String, (String, PathBuf)>::new();
335    for dependency in installed::resolve(project, distribution_root)? {
336        collect_project(
337            &dependency.project,
338            &format!("{}@{}", dependency.coordinate, dependency.version),
339            &mut declarations,
340            &mut resources,
341        )?;
342    }
343    collect_project(
344        project,
345        &format!("{}@{}", project.id, project.version),
346        &mut declarations,
347        &mut resources,
348    )?;
349    Ok(resources)
350}
351
352fn collect_project(
353    project: &Project,
354    owner: &str,
355    declarations: &mut BTreeMap<String, (String, PathBuf)>,
356    resources: &mut Vec<(String, String)>,
357) -> Result<(), String> {
358    for path in files_in(&project.root, &project.source_paths)? {
359        if project
360            .source_excludes
361            .iter()
362            .any(|excluded_root| path.starts_with(project.root.join(excluded_root)))
363        {
364            continue;
365        }
366        let source = fs::read_to_string(&path)
367            .map_err(|error| format!("cannot read {}: {error}", path.display()))?;
368        let namespace = declared_namespace(&source)
369            .map_err(|error| format!("{}: {error}", path.display()))?
370            .ok_or_else(|| format!("{} does not declare an ns or ns+ namespace", path.display()))?;
371        if let Some((previous_owner, previous_path)) =
372            declarations.insert(namespace.clone(), (owner.to_owned(), path.clone()))
373        {
374            return Err(format!(
375                "duplicate namespace {namespace}: {previous_owner} ({}) and {owner} ({})",
376                previous_path.display(),
377                path.display()
378            ));
379        }
380        resources.push((namespace, source));
381    }
382    Ok(())
383}
384
385fn dist_root() -> PathBuf {
386    if let Some(root) = std::env::var_os("HARA_DIST_HOME") {
387        return PathBuf::from(root);
388    }
389    std::env::var_os("HOME")
390        .map(PathBuf::from)
391        .unwrap_or_else(|| PathBuf::from("."))
392        .join(".hara/dist")
393}