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