Skip to main content

runmat_runtime/builtins/common/
path_state.rs

1//! MATLAB-style source search-path state shared by the `path` builtin and
2//! filesystem helpers such as `exist` or `which`.
3//!
4//! The MATLAB search path is represented as a single platform-specific string
5//! using the same separator rules that MathWorks MATLAB applies (`;` on
6//! Windows, `:` everywhere else).  RunMat keeps the current working directory
7//! separate from this list so callers can freely replace or manipulate the path
8//! without losing the implicit `pwd` entry that MATLAB always prioritises.
9//! Active runtime sessions provide their own [`SearchPath`]; the process state
10//! below remains as the initialization and standalone-builtin compatibility
11//! context.
12
13use once_cell::sync::Lazy;
14use std::sync::{
15    atomic::{AtomicU64, Ordering},
16    Arc, RwLock,
17};
18
19use crate::builtins::common::env as runtime_env;
20
21/// Platform-specific separator used when joining MATLAB path entries.
22pub const PATH_LIST_SEPARATOR: char = if cfg!(windows) { ';' } else { ':' };
23
24#[derive(Debug, Clone)]
25struct PathState {
26    /// Current MATLAB path string, excluding the implicit current directory.
27    current: String,
28}
29
30impl PathState {
31    fn initialise() -> Self {
32        Self {
33            current: initial_path_string(),
34        }
35    }
36}
37
38/// Ordered, session-owned source search path used by filesystem discovery and
39/// runtime callable resolution.
40#[derive(Debug)]
41pub struct SearchPath {
42    current: RwLock<String>,
43    generation: AtomicU64,
44}
45
46impl SearchPath {
47    pub fn new(current: String) -> Self {
48        Self {
49            current: RwLock::new(current),
50            generation: AtomicU64::new(0),
51        }
52    }
53
54    pub fn current_string(&self) -> String {
55        self.current
56            .read()
57            .map(|guard| guard.clone())
58            .unwrap_or_else(|poison| poison.into_inner().clone())
59    }
60
61    pub fn generation(&self) -> u64 {
62        self.generation.load(Ordering::Acquire)
63    }
64
65    fn replace(&self, new_path: &str) {
66        let mut guard = self
67            .current
68            .write()
69            .unwrap_or_else(|poison| poison.into_inner());
70        if *guard != new_path {
71            *guard = new_path.to_string();
72            self.generation.fetch_add(1, Ordering::AcqRel);
73        }
74    }
75
76    fn append(&self, segments: &[String]) {
77        if segments.is_empty() {
78            return;
79        }
80        let mut guard = self
81            .current
82            .write()
83            .unwrap_or_else(|poison| poison.into_inner());
84        let mut parts = split_segments(&guard);
85        parts.extend(segments.iter().cloned());
86        let next = join_parts(&parts);
87        if *guard != next {
88            *guard = next;
89            self.generation.fetch_add(1, Ordering::AcqRel);
90        }
91    }
92}
93
94fn initial_path_string() -> String {
95    let mut parts = Vec::<String>::new();
96    for var in ["RUNMAT_PATH", "MATLABPATH"] {
97        if let Ok(value) = runtime_env::var(var) {
98            parts.extend(
99                value
100                    .split(PATH_LIST_SEPARATOR)
101                    .map(|part| part.trim())
102                    .filter(|part| !part.is_empty())
103                    .map(|part| part.to_string()),
104            );
105        }
106    }
107    join_parts(&parts)
108}
109
110fn join_parts(parts: &[String]) -> String {
111    let mut joined = String::new();
112    for (idx, part) in parts.iter().enumerate() {
113        if idx > 0 {
114            joined.push(PATH_LIST_SEPARATOR);
115        }
116        joined.push_str(part);
117    }
118    joined
119}
120
121static PATH_STATE: Lazy<RwLock<PathState>> = Lazy::new(|| RwLock::new(PathState::initialise()));
122
123/// Return the current MATLAB path string (without the implicit current
124/// directory entry).
125pub fn current_path_string() -> String {
126    if let Some(search_path) = active_search_path() {
127        return search_path.current_string();
128    }
129    PATH_STATE
130        .read()
131        .map(|guard| guard.current.clone())
132        .unwrap_or_else(|poison| poison.into_inner().current.clone())
133}
134
135pub fn append_to_path(segments: &[String]) {
136    if let Some(search_path) = active_search_path() {
137        search_path.append(segments);
138        return;
139    }
140    if segments.is_empty() {
141        return;
142    }
143    let mut guard = PATH_STATE
144        .write()
145        .unwrap_or_else(|poison| poison.into_inner());
146    let mut parts = split_segments(&guard.current);
147    parts.extend(segments.iter().cloned());
148    guard.current = join_parts(&parts);
149}
150
151/// Replace the MATLAB path string for the active session. Without an active
152/// runtime context this updates the compatibility process state and mirrors it
153/// to `RUNMAT_PATH`.
154pub fn set_path_string(new_path: &str) {
155    if let Some(search_path) = active_search_path() {
156        search_path.replace(new_path);
157        return;
158    }
159    if new_path.is_empty() {
160        runtime_env::remove_var("RUNMAT_PATH");
161    } else {
162        runtime_env::set_var("RUNMAT_PATH", new_path);
163    }
164
165    let mut guard = PATH_STATE
166        .write()
167        .unwrap_or_else(|poison| poison.into_inner());
168    guard.current = new_path.to_string();
169}
170
171fn active_search_path() -> Option<Arc<SearchPath>> {
172    crate::user_functions::active_runtime_context().map(|context| Arc::clone(context.search_path()))
173}
174
175/// Split the current MATLAB path string into individual entries, omitting
176/// empty segments and trimming surrounding whitespace.
177pub fn current_path_segments() -> Vec<String> {
178    let path = current_path_string();
179    split_segments(&path)
180}
181
182fn split_segments(path: &str) -> Vec<String> {
183    path.split(PATH_LIST_SEPARATOR)
184        .map(|part| part.trim())
185        .filter(|part| !part.is_empty())
186        .map(|part| part.to_string())
187        .collect()
188}
189
190#[cfg(test)]
191pub(crate) mod tests {
192    use super::*;
193
194    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
195    #[test]
196    fn join_and_split_round_trip() {
197        let parts = vec!["/tmp/a".to_string(), "/tmp/b".to_string()];
198        let joined = join_parts(&parts);
199        assert_eq!(split_segments(&joined), parts);
200    }
201
202    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
203    #[test]
204    fn active_runtime_context_owns_path_and_generation() {
205        let first = Arc::new(SearchPath::new("first".to_string()));
206        let _first_context = crate::user_functions::install_runtime_context(Arc::new(
207            crate::user_functions::RuntimeContext::new(Arc::clone(&first)),
208        ));
209        assert_eq!(current_path_string(), "first");
210        set_path_string("first");
211        assert_eq!(first.generation(), 0, "no-op replacement is not a mutation");
212        let updated = format!("first{PATH_LIST_SEPARATOR}added");
213        set_path_string(&updated);
214        assert_eq!(current_path_string(), updated);
215        assert_eq!(first.generation(), 1);
216
217        let second = Arc::new(SearchPath::new("second".to_string()));
218        {
219            let _second_context = crate::user_functions::install_runtime_context(Arc::new(
220                crate::user_functions::RuntimeContext::new(second),
221            ));
222            assert_eq!(current_path_string(), "second");
223        }
224        assert_eq!(current_path_string(), updated);
225    }
226}