Skip to main content

runmat_runtime/builtins/common/
path_search.rs

1//! Shared helpers for searching the RunMat filesystem search path.
2//!
3//! The `exist`, `which`, and related REPL-facing builtins all need the same
4//! path canonicalisation and package-aware lookup logic. This module hosts
5//! reusable routines so each builtin can focus on user-facing semantics while
6//! sharing the platform-specific details for locating files, class folders,
7//! and packages.
8
9use runmat_filesystem as vfs;
10use std::collections::HashSet;
11use std::ffi::OsString;
12use std::path::{Path, PathBuf};
13
14use super::fs::expand_user_path;
15use super::path_state::current_path_segments;
16
17/// File extensions that identify compiled MEX binaries.
18pub const MEX_EXTENSIONS: &[&str] = &[
19    ".mexw64",
20    ".mexmaci64",
21    ".mexa64",
22    ".mexglx",
23    ".mexw32",
24    ".mexmaci",
25    ".mex",
26];
27
28/// File extensions that identify MATLAB P-code artefacts.
29pub const PCODE_EXTENSIONS: &[&str] = &[".p", ".pp"];
30
31/// File extensions that identify Simulink models.
32pub const SIMULINK_EXTENSIONS: &[&str] = &[".slx", ".mdl"];
33
34/// File extensions that identify thunk libraries generated by MATLAB.
35pub const THUNK_EXTENSIONS: &[&str] = &[".thunk"];
36
37/// File extensions that identify native libraries.
38pub const LIB_EXTENSIONS: &[&str] = &[".dll", ".so", ".dylib", ".lib", ".a"];
39
40/// File extensions that should be considered when searching for class
41/// definitions implemented in MATLAB source files.
42pub const CLASS_M_FILE_EXTENSIONS: &[&str] = &[".m"];
43
44/// General-purpose extensions searched by MATLAB/RunMat when resolving
45/// scripts and functions on the search path.
46pub const GENERAL_FILE_EXTENSIONS: &[&str] = &[
47    ".m",
48    ".mlx",
49    ".mlapp",
50    ".mltbx",
51    ".mlappinstall",
52    ".mat",
53    ".fig",
54    ".txt",
55    ".csv",
56    ".json",
57    ".xml",
58    ".dat",
59    ".bin",
60    ".h",
61    ".hpp",
62    ".c",
63    ".cc",
64    ".cpp",
65    ".cxx",
66    ".py",
67    ".sh",
68    ".bat",
69    "",
70];
71
72/// Known file extensions used to heuristically decide whether a symbol is
73/// likely a file path instead of a bare function name.
74pub const KNOWN_FILE_EXTENSIONS: &[&str] = &[
75    "m",
76    "mlx",
77    "mlapp",
78    "mat",
79    "mex",
80    "mexw64",
81    "mexmaci64",
82    "mexa64",
83    "mexglx",
84    "mexw32",
85    "mexmaci",
86    "p",
87    "pp",
88    "slx",
89    "mdl",
90    "mltbx",
91    "mlappinstall",
92    "fig",
93    "txt",
94    "csv",
95    "json",
96    "xml",
97    "dat",
98    "bin",
99    "dll",
100    "so",
101    "dylib",
102    "lib",
103    "a",
104    "thunk",
105    "h",
106    "hpp",
107    "c",
108    "cc",
109    "cpp",
110    "cxx",
111    "py",
112    "sh",
113    "bat",
114];
115
116/// Return the ordered list of directories searched when resolving MATLAB
117/// functions. The list begins with the current working directory followed by
118/// entries from `RUNMAT_PATH` and `MATLABPATH`. Duplicates are removed while
119/// preserving the first occurrence.
120pub fn search_directories(error_prefix: &str) -> Result<Vec<PathBuf>, String> {
121    let mut dirs = Vec::new();
122    let mut seen = HashSet::new();
123
124    if let Ok(cwd) = vfs::current_dir() {
125        push_unique_dir(&mut dirs, &mut seen, cwd);
126    } else {
127        return Err(format!(
128            "{error_prefix}: unable to determine current directory"
129        ));
130    }
131
132    for entry in current_path_segments() {
133        let expanded = expand_user_path(&entry, error_prefix)?;
134        push_unique_dir(&mut dirs, &mut seen, PathBuf::from(expanded));
135    }
136
137    Ok(dirs)
138}
139
140/// Split a potentially package-qualified name into the package components and
141/// the base symbol name.
142pub fn split_package_components(name: &str) -> (Vec<String>, String) {
143    if name.is_empty() {
144        return (Vec::new(), String::new());
145    }
146    let mut parts: Vec<&str> = name.split('.').collect();
147    if parts.len() == 1 {
148        return (Vec::new(), parts[0].to_string());
149    }
150    let base = parts.pop().unwrap_or_default().to_string();
151    let packages = parts.into_iter().map(|p| p.to_string()).collect();
152    (packages, base)
153}
154
155/// Convert package components into the MATLAB directory structure (`+pkg`).
156pub fn packages_to_path(packages: &[String]) -> PathBuf {
157    let mut path = PathBuf::new();
158    for pkg in packages {
159        path.push(format!("+{}", pkg));
160    }
161    path
162}
163
164/// Return `true` when the supplied name should be treated as a filesystem path
165/// instead of a bare MATLAB symbol.
166pub fn should_treat_as_path(name: &str) -> bool {
167    if name.starts_with('~')
168        || name.starts_with('@')
169        || name.starts_with('+')
170        || name.contains('/')
171        || name.contains('\\')
172    {
173        return true;
174    }
175    if cfg!(windows) && has_windows_drive_prefix(name) {
176        return true;
177    }
178    is_probable_filename(name)
179}
180
181fn has_windows_drive_prefix(name: &str) -> bool {
182    let bytes = name.as_bytes();
183    if bytes.len() < 2 {
184        return false;
185    }
186    bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
187}
188
189fn is_probable_filename(name: &str) -> bool {
190    if let Some(dot) = name.rfind('.') {
191        let ext = &name[dot + 1..];
192        let lowered = ext.to_ascii_lowercase();
193        KNOWN_FILE_EXTENSIONS.contains(&lowered.as_str())
194    } else {
195        false
196    }
197}
198
199/// Return candidate paths for the provided name using the supplied list of
200/// file extensions. The candidates are not filtered for existence.
201pub fn file_candidates(
202    name: &str,
203    extensions: &[&str],
204    error_prefix: &str,
205) -> Result<Vec<PathBuf>, String> {
206    if should_treat_as_path(name) {
207        collect_direct_file_candidates(name, extensions, error_prefix)
208    } else {
209        collect_package_file_candidates(name, extensions, error_prefix)
210    }
211}
212
213fn collect_direct_file_candidates(
214    name: &str,
215    extensions: &[&str],
216    error_prefix: &str,
217) -> Result<Vec<PathBuf>, String> {
218    let expanded = expand_user_path(name, error_prefix)?;
219    let base = PathBuf::from(&expanded);
220    let mut candidates = vec![base.clone()];
221    if base.extension().is_none() {
222        for &ext in extensions {
223            if ext.is_empty() {
224                continue;
225            }
226            candidates.push(append_extension(&base, ext));
227        }
228    }
229    Ok(candidates)
230}
231
232fn collect_package_file_candidates(
233    name: &str,
234    extensions: &[&str],
235    error_prefix: &str,
236) -> Result<Vec<PathBuf>, String> {
237    let (packages, base_name) = split_package_components(name);
238    let prefix = packages_to_path(&packages);
239    let mut candidates = Vec::new();
240
241    for dir in search_directories(error_prefix)? {
242        let mut root = dir.clone();
243        if !prefix.as_os_str().is_empty() {
244            root.push(&prefix);
245        }
246        let base_path = root.join(&base_name);
247        push_unique(&mut candidates, base_path.clone());
248        for &ext in extensions {
249            if ext.is_empty() {
250                continue;
251            }
252            push_unique(&mut candidates, append_extension(&base_path, ext));
253        }
254    }
255
256    Ok(candidates)
257}
258
259fn append_extension(path: &Path, ext: &str) -> PathBuf {
260    if ext.is_empty() {
261        return path.to_path_buf();
262    }
263    let mut os: OsString = path.as_os_str().to_os_string();
264    os.push(ext);
265    PathBuf::from(os)
266}
267
268/// Return the first existing file for a symbol given the list of candidate
269/// extensions.
270pub async fn find_file_with_extensions(
271    name: &str,
272    extensions: &[&str],
273    error_prefix: &str,
274) -> Result<Option<PathBuf>, String> {
275    let candidates = file_candidates(name, extensions, error_prefix)?;
276    for path in candidates {
277        if path_is_file(&path).await {
278            return Ok(Some(path));
279        }
280    }
281    Ok(None)
282}
283
284/// Return all existing files for a symbol given the list of candidate
285/// extensions. Duplicates are removed while preserving discovery order.
286pub async fn find_all_files_with_extensions(
287    name: &str,
288    extensions: &[&str],
289    error_prefix: &str,
290) -> Result<Vec<PathBuf>, String> {
291    let mut matches = Vec::new();
292    let mut seen = HashSet::new();
293    for path in file_candidates(name, extensions, error_prefix)? {
294        if path_is_file(&path).await && seen.insert(path.clone()) {
295            matches.push(path);
296        }
297    }
298    Ok(matches)
299}
300
301/// Return the list of candidate directories that could correspond to `name`.
302pub fn directory_candidates(name: &str, error_prefix: &str) -> Result<Vec<PathBuf>, String> {
303    if should_treat_as_path(name) {
304        let expanded = expand_user_path(name, error_prefix)?;
305        return Ok(vec![PathBuf::from(expanded)]);
306    }
307    let (packages, base) = split_package_components(name);
308    let prefix = packages_to_path(&packages);
309    let mut candidates = Vec::new();
310    for dir in search_directories(error_prefix)? {
311        let mut path = dir.clone();
312        if !prefix.as_os_str().is_empty() {
313            path.push(&prefix);
314        }
315        path.push(&base);
316        push_unique(&mut candidates, path);
317    }
318    Ok(candidates)
319}
320
321/// Return candidate class folders (`@ClassName`) for the provided symbol.
322pub fn class_folder_candidates(name: &str, error_prefix: &str) -> Result<Vec<PathBuf>, String> {
323    if should_treat_as_path(name) {
324        let expanded = expand_user_path(name, error_prefix)?;
325        return Ok(vec![PathBuf::from(expanded)]);
326    }
327    let (packages, class_name) = split_package_components(name);
328    let prefix = packages_to_path(&packages);
329    let mut candidates = Vec::new();
330    for dir in search_directories(error_prefix)? {
331        let mut path = dir.clone();
332        if !prefix.as_os_str().is_empty() {
333            path.push(&prefix);
334        }
335        path.push(format!("@{}", class_name));
336        push_unique(&mut candidates, path);
337    }
338    Ok(candidates)
339}
340
341/// Return `true` when a MATLAB class definition exists in a file and contains
342/// the specified keyword (typically `classdef`).
343pub async fn class_file_exists(
344    name: &str,
345    class_extensions: &[&str],
346    keyword: &str,
347    error_prefix: &str,
348) -> Result<bool, String> {
349    if let Some(path) = find_file_with_extensions(name, class_extensions, error_prefix).await? {
350        if file_contains_keyword(&path, keyword).await {
351            return Ok(true);
352        }
353    }
354    Ok(false)
355}
356
357/// Return every class definition file that contains the required keyword.
358pub async fn class_file_paths(
359    name: &str,
360    class_extensions: &[&str],
361    keyword: &str,
362    error_prefix: &str,
363) -> Result<Vec<PathBuf>, String> {
364    let mut matches = Vec::new();
365    for path in find_all_files_with_extensions(name, class_extensions, error_prefix).await? {
366        if file_contains_keyword(&path, keyword).await {
367            matches.push(path);
368        }
369    }
370    Ok(matches)
371}
372
373async fn file_contains_keyword(path: &Path, keyword: &str) -> bool {
374    const MAX_BYTES: usize = 64 * 1024;
375    match vfs::read_async(path).await {
376        Ok(mut buffer) => {
377            if buffer.len() > MAX_BYTES {
378                buffer.truncate(MAX_BYTES);
379            }
380            let text = String::from_utf8_lossy(&buffer);
381            text.to_ascii_lowercase()
382                .contains(&keyword.to_ascii_lowercase())
383        }
384        Err(_) => false,
385    }
386}
387
388fn push_unique<T: Eq + std::hash::Hash + Clone>(vec: &mut Vec<T>, value: T) {
389    if !vec.contains(&value) {
390        vec.push(value);
391    }
392}
393
394fn push_unique_dir(vec: &mut Vec<PathBuf>, seen: &mut HashSet<PathBuf>, value: PathBuf) {
395    if seen.insert(value.clone()) {
396        vec.push(value);
397    }
398}
399
400pub async fn path_is_file(path: &Path) -> bool {
401    vfs::metadata_async(path)
402        .await
403        .map(|meta| meta.is_file())
404        .unwrap_or(false)
405}
406
407pub async fn path_is_directory(path: &Path) -> bool {
408    vfs::metadata_async(path)
409        .await
410        .map(|meta| meta.is_dir())
411        .unwrap_or(false)
412}