Skip to main content

runmat_config/project/
source_index.rs

1use super::manifest::{path_is_dir_async, ProjectManifest};
2use serde::{Deserialize, Serialize};
3use std::fs;
4use std::path::{Path, PathBuf};
5use thiserror::Error;
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
8pub struct ProjectSourceIndex {
9    pub files: Vec<ProjectSourceFile>,
10    pub package_dirs: Vec<PathBuf>,
11    pub class_dirs: Vec<PathBuf>,
12    pub private_dirs: Vec<PathBuf>,
13}
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct ProjectSourceFile {
17    pub source_root: PathBuf,
18    pub relative_path: PathBuf,
19    pub qualified_name: String,
20    #[serde(default)]
21    pub package_path: Option<String>,
22    #[serde(default)]
23    pub class_name: Option<String>,
24    /// Canonical class scope contributed by `@Class` folders. This intentionally
25    /// excludes the file stem: `@Report/Report.m` and `@Report/title.m` both
26    /// belong to class `Report`, while their callable file identities remain
27    /// `Report.Report` and `Report.title` respectively.
28    #[serde(default)]
29    pub class_qualified_name: Option<String>,
30    pub is_private: bool,
31}
32
33impl ProjectSourceFile {
34    /// The canonical name to apply to a parsed `classdef` source.
35    pub fn class_definition_qualified_name(&self) -> Option<&str> {
36        self.class_qualified_name.as_deref().or_else(|| {
37            self.package_path
38                .as_ref()
39                .map(|_| self.qualified_name.as_str())
40        })
41    }
42
43    /// The callable identity for a parsed function or class-folder member file.
44    pub fn function_qualified_name(&self) -> Option<&str> {
45        if self.is_private {
46            return None;
47        }
48        (self.package_path.is_some() || self.class_name.is_some())
49            .then_some(self.qualified_name.as_str())
50            .filter(|name| name.contains('.'))
51    }
52}
53
54#[derive(Debug, Error)]
55pub enum ProjectSourceIndexError {
56    #[error("source root does not exist or is not a directory: {root}")]
57    InvalidSourceRoot { root: PathBuf },
58    #[error("failed to read source path {path}: {source}")]
59    ReadDir {
60        path: PathBuf,
61        #[source]
62        source: std::io::Error,
63    },
64    #[error("failed to read source entry under {path}: {source}")]
65    ReadEntry {
66        path: PathBuf,
67        #[source]
68        source: std::io::Error,
69    },
70}
71
72pub fn build_project_source_index(
73    project_root: &Path,
74    manifest: &ProjectManifest,
75) -> Result<ProjectSourceIndex, ProjectSourceIndexError> {
76    let mut index = ProjectSourceIndex::default();
77    for source_root in &manifest.sources.roots {
78        let absolute_root = project_root.join(source_root);
79        if !absolute_root.is_dir() {
80            return Err(ProjectSourceIndexError::InvalidSourceRoot {
81                root: source_root.clone(),
82            });
83        }
84        scan_source_dir(
85            &absolute_root,
86            &absolute_root,
87            source_root,
88            &ScanState::default(),
89            &mut index,
90            project_root,
91        )?;
92    }
93    normalize_source_index(&mut index);
94    Ok(index)
95}
96
97pub async fn build_project_source_index_async(
98    project_root: &Path,
99    manifest: &ProjectManifest,
100) -> Result<ProjectSourceIndex, ProjectSourceIndexError> {
101    let mut index = ProjectSourceIndex::default();
102    for source_root in &manifest.sources.roots {
103        let absolute_root = project_root.join(source_root);
104        if !path_is_dir_async(&absolute_root).await {
105            return Err(ProjectSourceIndexError::InvalidSourceRoot {
106                root: source_root.clone(),
107            });
108        }
109        scan_source_dir_async(
110            &absolute_root,
111            &absolute_root,
112            source_root,
113            &ScanState::default(),
114            &mut index,
115            project_root,
116        )
117        .await?;
118    }
119    normalize_source_index(&mut index);
120    Ok(index)
121}
122
123/// Build the MATLAB lookup index for a folder that has no `runmat.toml`.
124pub fn build_loose_source_index(
125    root: &Path,
126) -> Result<ProjectSourceIndex, ProjectSourceIndexError> {
127    let mut index = ProjectSourceIndex::default();
128    let entries = fs::read_dir(root).map_err(|source| ProjectSourceIndexError::ReadDir {
129        path: root.to_path_buf(),
130        source,
131    })?;
132    let mut entries = entries
133        .map(|entry| {
134            entry.map_err(|source| ProjectSourceIndexError::ReadEntry {
135                path: root.to_path_buf(),
136                source,
137            })
138        })
139        .collect::<Result<Vec<_>, _>>()?;
140    entries.sort_by_key(|entry| entry.file_name());
141
142    for entry in entries {
143        let path = entry.path();
144        let file_type = entry
145            .file_type()
146            .map_err(|source| ProjectSourceIndexError::ReadEntry {
147                path: root.to_path_buf(),
148                source,
149            })?;
150        if file_type.is_dir() {
151            let name = entry.file_name();
152            let name = name.to_string_lossy();
153            if name.starts_with('+') || name.starts_with('@') || name == "private" {
154                scan_source_dir(
155                    &path,
156                    root,
157                    Path::new("."),
158                    &ScanState::default(),
159                    &mut index,
160                    root,
161                )?;
162            }
163            continue;
164        }
165        if let Some(source) = project_source_file_from_path(&path, root, Path::new(".")) {
166            index.files.push(source);
167        }
168    }
169    normalize_source_index(&mut index);
170    Ok(index)
171}
172
173pub async fn build_loose_source_index_async(
174    root: &Path,
175) -> Result<ProjectSourceIndex, ProjectSourceIndexError> {
176    let mut index = ProjectSourceIndex::default();
177    let mut entries = runmat_filesystem::read_dir_async(root)
178        .await
179        .map_err(|source| ProjectSourceIndexError::ReadDir {
180            path: root.to_path_buf(),
181            source,
182        })?;
183    entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_string());
184
185    for entry in entries {
186        let path = entry.path().to_path_buf();
187        if entry.is_dir() {
188            let name = entry.file_name().to_string_lossy().to_string();
189            if name.starts_with('+') || name.starts_with('@') || name == "private" {
190                scan_source_dir_async(
191                    &path,
192                    root,
193                    Path::new("."),
194                    &ScanState::default(),
195                    &mut index,
196                    root,
197                )
198                .await?;
199            }
200            continue;
201        }
202        if let Some(source) = project_source_file_from_path(&path, root, Path::new(".")) {
203            index.files.push(source);
204        }
205    }
206    normalize_source_index(&mut index);
207    Ok(index)
208}
209
210fn normalize_source_index(index: &mut ProjectSourceIndex) {
211    index
212        .files
213        .sort_by(|left, right| left.relative_path.cmp(&right.relative_path));
214    index.package_dirs.sort();
215    index.package_dirs.dedup();
216    index.class_dirs.sort();
217    index.class_dirs.dedup();
218    index.private_dirs.sort();
219    index.private_dirs.dedup();
220}
221
222#[derive(Debug, Clone, Default)]
223struct ScanState {
224    package_segments: Vec<String>,
225    module_segments: Vec<String>,
226    class_name: Option<String>,
227    in_private: bool,
228}
229
230/// Normalize one MATLAB source path into the identity used by project
231/// composition and loose-file discovery.
232pub fn project_source_file_from_path(
233    source_path: &Path,
234    root_dir: &Path,
235    source_root: &Path,
236) -> Option<ProjectSourceFile> {
237    let relative_path = source_path.strip_prefix(root_dir).ok()?.to_path_buf();
238    if !source_path
239        .extension()
240        .and_then(|extension| extension.to_str())
241        .is_some_and(|extension| extension.eq_ignore_ascii_case("m"))
242    {
243        return None;
244    }
245    let stem = source_path.file_stem()?.to_str()?.trim();
246    if stem.is_empty() {
247        return None;
248    }
249
250    let mut state = ScanState::default();
251    if let Some(parent) = relative_path.parent() {
252        for component in parent.components() {
253            let segment = component.as_os_str().to_str()?;
254            if let Some(package) = segment.strip_prefix('+') {
255                if package.is_empty() {
256                    return None;
257                }
258                state.package_segments.push(package.to_string());
259            } else if let Some(class) = segment.strip_prefix('@') {
260                if class.is_empty() {
261                    return None;
262                }
263                state.class_name = Some(class.to_string());
264            } else if segment == "private" {
265                state.in_private = true;
266            } else {
267                state.module_segments.push(segment.to_string());
268            }
269        }
270    }
271
272    let mut qualified_segments = state.package_segments.clone();
273    qualified_segments.extend(state.module_segments.iter().cloned());
274    let class_qualified_name = state.class_name.as_ref().map(|class_name| {
275        let mut class_segments = qualified_segments.clone();
276        class_segments.push(class_name.clone());
277        class_segments.join(".")
278    });
279    if let Some(class_name) = &state.class_name {
280        qualified_segments.push(class_name.clone());
281    }
282    qualified_segments.push(stem.to_string());
283    let qualified_name = qualified_segments.join(".");
284    (!qualified_name.is_empty()).then_some(ProjectSourceFile {
285        source_root: source_root.to_path_buf(),
286        relative_path,
287        qualified_name,
288        package_path: (!state.package_segments.is_empty())
289            .then(|| state.package_segments.join(".")),
290        class_name: state.class_name,
291        class_qualified_name,
292        is_private: state.in_private,
293    })
294}
295
296fn scan_source_dir(
297    dir: &Path,
298    root_absolute: &Path,
299    source_root: &Path,
300    state: &ScanState,
301    index: &mut ProjectSourceIndex,
302    project_root: &Path,
303) -> Result<(), ProjectSourceIndexError> {
304    let mut entries = fs::read_dir(dir).map_err(|source| ProjectSourceIndexError::ReadDir {
305        path: dir.to_path_buf(),
306        source,
307    })?;
308    let mut sorted = Vec::new();
309    for entry in &mut entries {
310        sorted.push(entry.map_err(|source| ProjectSourceIndexError::ReadEntry {
311            path: dir.to_path_buf(),
312            source,
313        })?);
314    }
315    sorted.sort_by_key(|entry| entry.file_name());
316
317    for entry in sorted {
318        let path = entry.path();
319        let file_type = entry
320            .file_type()
321            .map_err(|source| ProjectSourceIndexError::ReadEntry {
322                path: dir.to_path_buf(),
323                source,
324            })?;
325        let name = entry.file_name();
326        let name = name.to_string_lossy();
327        if file_type.is_dir() {
328            let mut next = state.clone();
329            if let Some(package) = name.strip_prefix('+') {
330                if !package.is_empty() {
331                    next.package_segments.push(package.to_string());
332                    if let Ok(relative) = path.strip_prefix(project_root) {
333                        index.package_dirs.push(relative.to_path_buf());
334                    }
335                }
336            } else if let Some(class) = name.strip_prefix('@') {
337                if !class.is_empty() {
338                    next.class_name = Some(class.to_string());
339                    if let Ok(relative) = path.strip_prefix(project_root) {
340                        index.class_dirs.push(relative.to_path_buf());
341                    }
342                }
343            } else if name == "private" {
344                next.in_private = true;
345                if let Ok(relative) = path.strip_prefix(project_root) {
346                    index.private_dirs.push(relative.to_path_buf());
347                }
348            } else {
349                next.module_segments.push(name.to_string());
350            }
351            scan_source_dir(
352                &path,
353                root_absolute,
354                source_root,
355                &next,
356                index,
357                project_root,
358            )?;
359            continue;
360        }
361        if let Some(source) = project_source_file_from_path(&path, root_absolute, source_root) {
362            index.files.push(source);
363        }
364    }
365    Ok(())
366}
367
368async fn scan_source_dir_async(
369    dir: &Path,
370    root_absolute: &Path,
371    source_root: &Path,
372    state: &ScanState,
373    index: &mut ProjectSourceIndex,
374    project_root: &Path,
375) -> Result<(), ProjectSourceIndexError> {
376    let mut stack = vec![(dir.to_path_buf(), state.clone())];
377    while let Some((current_dir, current_state)) = stack.pop() {
378        let mut sorted = runmat_filesystem::read_dir_async(&current_dir)
379            .await
380            .map_err(|source| ProjectSourceIndexError::ReadDir {
381                path: current_dir.clone(),
382                source,
383            })?;
384        sorted.sort_by_key(|entry| entry.file_name().to_string_lossy().to_string());
385
386        for entry in sorted {
387            let path = entry.path().to_path_buf();
388            let name = entry.file_name().to_string_lossy().to_string();
389            if entry.is_dir() {
390                let mut next = current_state.clone();
391                if let Some(package) = name.strip_prefix('+') {
392                    if !package.is_empty() {
393                        next.package_segments.push(package.to_string());
394                        if let Ok(relative) = path.strip_prefix(project_root) {
395                            index.package_dirs.push(relative.to_path_buf());
396                        }
397                    }
398                } else if let Some(class) = name.strip_prefix('@') {
399                    if !class.is_empty() {
400                        next.class_name = Some(class.to_string());
401                        if let Ok(relative) = path.strip_prefix(project_root) {
402                            index.class_dirs.push(relative.to_path_buf());
403                        }
404                    }
405                } else if name == "private" {
406                    next.in_private = true;
407                    if let Ok(relative) = path.strip_prefix(project_root) {
408                        index.private_dirs.push(relative.to_path_buf());
409                    }
410                } else {
411                    next.module_segments.push(name);
412                }
413                stack.push((path, next));
414                continue;
415            }
416            if let Some(source) = project_source_file_from_path(&path, root_absolute, source_root) {
417                index.files.push(source);
418            }
419        }
420    }
421    Ok(())
422}