1use std::borrow::Cow;
17use std::cell::RefCell;
18
19#[derive(Clone, Default)]
23pub struct PathCtx {
24 pub cwd: Option<String>,
25 pub root: Option<String>,
26}
27
28thread_local! {
29 static CURRENT: RefCell<PathCtx> = RefCell::new(PathCtx::default());
30}
31
32#[must_use]
35pub fn enter(ctx: PathCtx) -> Guard {
36 Guard(CURRENT.with(|c| c.replace(ctx)))
37}
38
39pub struct Guard(PathCtx);
41
42impl Drop for Guard {
43 fn drop(&mut self) {
44 CURRENT.with(|c| *c.borrow_mut() = std::mem::take(&mut self.0));
45 }
46}
47
48#[must_use]
51pub fn enter_cwd(cwd: Option<String>) -> Guard {
52 Guard(CURRENT.with(|c| {
53 let mut b = c.borrow_mut();
54 PathCtx { cwd: std::mem::replace(&mut b.cwd, cwd), root: b.root.clone() }
55 }))
56}
57
58pub fn cwd() -> Option<String> {
60 CURRENT.with(|c| c.borrow().cwd.clone())
61}
62
63pub fn root() -> Option<String> {
66 CURRENT.with(|c| {
67 let b = c.borrow();
68 b.root.clone().or_else(|| b.cwd.clone())
69 })
70}
71
72struct LoopVar {
76 name: String,
77 read_repr: String,
78 write_repr: String,
79}
80
81thread_local! {
82 static LOOP_VARS: RefCell<Vec<LoopVar>> = const { RefCell::new(Vec::new()) };
83}
84
85struct VarBinding {
88 name: String,
89 value: String,
90}
91
92thread_local! {
93 static VARS: RefCell<Vec<VarBinding>> = const { RefCell::new(Vec::new()) };
94}
95
96#[must_use]
102pub fn enter_var(name: String, value: String) -> VarGuard {
103 VARS.with(|v| v.borrow_mut().push(VarBinding { name, value }));
104 VarGuard
105}
106
107pub struct VarGuard;
108
109impl Drop for VarGuard {
110 fn drop(&mut self) {
111 VARS.with(|v| {
112 v.borrow_mut().pop();
113 });
114 }
115}
116
117#[must_use]
120pub fn enter_loop_var(name: String, read_repr: String, write_repr: String) -> LoopGuard {
121 LOOP_VARS.with(|v| v.borrow_mut().push(LoopVar { name, read_repr, write_repr }));
122 LoopGuard
123}
124
125pub struct LoopGuard;
127
128impl Drop for LoopGuard {
129 fn drop(&mut self) {
130 LOOP_VARS.with(|v| {
131 v.borrow_mut().pop();
132 });
133 }
134}
135
136thread_local! {
137 static STDIN_REPR: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
138}
139
140#[must_use]
145pub fn enter_stdin_repr(repr: String) -> StdinReprGuard {
146 STDIN_REPR.with(|v| v.borrow_mut().push(repr));
147 StdinReprGuard
148}
149
150pub fn stdin_item_repr() -> Option<String> {
153 STDIN_REPR.with(|v| v.borrow().last().cloned())
154}
155
156pub struct StdinReprGuard;
157
158impl Drop for StdinReprGuard {
159 fn drop(&mut self) {
160 STDIN_REPR.with(|v| {
161 v.borrow_mut().pop();
162 });
163 }
164}
165
166pub fn expand_vars(path: &str, want_write: bool) -> Cow<'_, str> {
171 if !path.contains('$') {
172 return Cow::Borrowed(path);
173 }
174 let replaced = LOOP_VARS.with(|lv| {
175 VARS.with(|v| {
176 let loops = lv.borrow();
177 let vars = v.borrow();
178 if loops.is_empty() && vars.is_empty() {
179 None
180 } else {
181 expand_with(path, &loops, &vars, want_write)
182 }
183 })
184 });
185 replaced.map_or(Cow::Borrowed(path), Cow::Owned)
186}
187
188fn expand_with(path: &str, loops: &[LoopVar], vars: &[VarBinding], want_write: bool) -> Option<String> {
189 let mut out = String::with_capacity(path.len());
190 let mut rest = path;
191 let mut replaced = false;
192 while let Some(dollar) = rest.find('$') {
193 out.push_str(&rest[..dollar]);
194 let after = &rest[dollar + 1..];
195 match parse_var(after) {
196 Some((name, consumed)) => {
197 if let Some(lv) = loops.iter().rev().find(|v| v.name == name) {
200 out.push_str(if want_write { &lv.write_repr } else { &lv.read_repr });
201 replaced = true;
202 } else if let Some(vb) = vars.iter().rev().find(|v| v.name == name) {
203 out.push_str(&vb.value);
204 replaced = true;
205 } else {
206 out.push('$');
207 out.push_str(&after[..consumed]);
208 }
209 rest = &after[consumed..];
210 }
211 None => {
212 out.push('$');
213 rest = after;
214 }
215 }
216 }
217 out.push_str(rest);
218 replaced.then_some(out)
219}
220
221fn parse_var(after: &str) -> Option<(&str, usize)> {
225 if let Some(braced) = after.strip_prefix('{') {
226 let close = braced.find('}')?;
227 let name = &braced[..close];
228 is_var_name(name).then_some((name, close + 2)) } else if after.as_bytes().first().is_some_and(u8::is_ascii_digit) {
230 Some((&after[..1], 1)) } else {
232 let len = after.bytes().take_while(|&b| b.is_ascii_alphanumeric() || b == b'_').count();
233 let name = &after[..len];
234 is_var_name(name).then_some((name, len))
235 }
236}
237
238fn is_var_name(s: &str) -> bool {
241 if s.is_empty() {
242 return false;
243 }
244 if s.bytes().all(|b| b.is_ascii_digit()) {
245 return true;
246 }
247 let mut bytes = s.bytes();
248 matches!(bytes.next(), Some(b) if b.is_ascii_alphabetic() || b == b'_')
249 && bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_')
250}
251
252pub fn resolve(path: &str) -> Cow<'_, str> {
262 if path.is_empty() || path.starts_with('~') || path.contains('$') {
264 return Cow::Borrowed(path);
265 }
266 let resolved = CURRENT.with(|c| {
267 let ctx = c.borrow();
268 match (ctx.cwd.as_deref(), ctx.root.as_deref()) {
269 (Some(cwd), Some(root)) if cwd.starts_with('/') && root.starts_with('/') => {
270 let abs = if path.starts_with('/') {
273 lexical_join("/", path)
274 } else {
275 lexical_join(cwd, path)
276 };
277 Some(express_relative_to_root(&abs, root))
278 }
279 _ => None,
280 }
281 });
282 resolved.map_or(Cow::Borrowed(path), Cow::Owned)
283}
284
285pub fn join_cwd(cur: Option<&str>, target: &str) -> Option<String> {
290 if target.starts_with('~') || target.contains('$') {
291 return None; }
293 if target.starts_with('/') {
294 return Some(lexical_join("/", target)); }
296 cur.filter(|c| c.starts_with('/')).map(|c| lexical_join(c, target)) }
298
299fn express_relative_to_root(abs: &str, root: &str) -> String {
304 let root = root.trim_end_matches('/');
305 if abs == root {
306 return ".".to_string(); }
308 match abs.strip_prefix(root) {
309 Some(inside) if inside.starts_with('/') => inside.trim_start_matches('/').to_string(),
310 _ => abs.to_string(),
311 }
312}
313
314fn lexical_join(base: &str, rel: &str) -> String {
317 let mut parts: Vec<&str> = base.split('/').filter(|s| !s.is_empty()).collect();
318 for seg in rel.split('/') {
319 match seg {
320 "" | "." => {}
321 ".." => {
322 parts.pop();
323 }
324 s => parts.push(s),
325 }
326 }
327 format!("/{}", parts.join("/"))
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333
334 #[test]
335 fn no_context_leaves_paths_unchanged() {
336 assert_eq!(resolve("./x"), "./x");
337 assert_eq!(resolve("config"), "config");
338 assert_eq!(resolve("/etc/x"), "/etc/x");
339 }
340
341 #[test]
342 fn relative_inside_the_project_stays_worktree_relative() {
343 let _g = enter(PathCtx { cwd: Some("/home/u/proj/sub".into()), root: Some("/home/u/proj".into()) });
344 assert_eq!(resolve("x"), "sub/x", "cwd under root → root-relative");
345 assert_eq!(resolve("./y"), "sub/y");
346 assert_eq!(resolve("../z"), "z", ".. that stays inside root");
347 }
348
349 #[test]
350 fn relative_outside_the_project_becomes_absolute() {
351 let _g = enter(PathCtx { cwd: Some("/etc".into()), root: Some("/home/u/proj".into()) });
352 assert_eq!(resolve("x"), "/etc/x", "cd /etc → the real target");
353 assert_eq!(resolve("passwd"), "/etc/passwd");
354 assert_eq!(resolve("*"), "/etc/*");
355 }
356
357 #[test]
358 fn dotdot_escaping_the_project_becomes_absolute() {
359 let _g = enter(PathCtx { cwd: Some("/home/u/proj".into()), root: Some("/home/u/proj".into()) });
360 assert_eq!(resolve("../../../etc/x"), "/etc/x");
361 }
362
363 #[test]
364 fn absolute_in_root_becomes_root_relative_outside_stays_absolute() {
365 let _g = enter(PathCtx { cwd: Some("/home/u/proj/sub".into()), root: Some("/home/u/proj".into()) });
366 assert_eq!(resolve("/home/u/proj/main.rs"), "main.rs");
368 assert_eq!(resolve("/home/u/proj/sub/x"), "sub/x");
369 assert_eq!(resolve("/home/u/proj/a/../b"), "b", "normalized in place");
370 assert_eq!(resolve("/home/u/proj"), ".", "the project root itself");
371 assert_eq!(resolve("/usr/bin/x"), "/usr/bin/x");
373 assert_eq!(resolve("/home/u/proj/../../etc/x"), "/home/etc/x", "climbs to /home, still outside root");
374 assert_eq!(resolve("/home/u/proj/../../../etc/x"), "/etc/x", "escapes to /etc via ..");
375 assert_eq!(
376 resolve("/home/u/proj-evil/secret"), "/home/u/proj-evil/secret",
377 "a sibling dir is not confused for inside by bare string prefix",
378 );
379 assert_eq!(resolve("$HOME/x"), "$HOME/x");
381 assert_eq!(resolve("~/x"), "~/x");
382 }
383
384 #[test]
385 fn loop_var_expands_to_its_representative_per_face() {
386 let _g = enter_loop_var("f".into(), "read_item".into(), "write_item".into());
387 assert_eq!(expand_vars("$f", false), "read_item");
388 assert_eq!(expand_vars("$f", true), "write_item");
389 assert_eq!(expand_vars("${f}", false), "read_item");
390 assert_eq!(expand_vars("$f.bak", false), "read_item.bak", "compound suffix");
391 assert_eq!(expand_vars("pre/$f", false), "pre/read_item");
392 assert_eq!(expand_vars("$foo", false), "$foo", "$foo is not $f");
393 assert_eq!(expand_vars("$g", false), "$g", "unbound var untouched");
394 assert_eq!(expand_vars("plain", false), "plain");
395 }
396
397 #[test]
398 fn loop_var_binding_is_scoped_and_nests() {
399 assert_eq!(expand_vars("$f", false), "$f", "no binding");
400 {
401 let _outer = enter_loop_var("f".into(), "outer".into(), "outer".into());
402 {
403 let _inner = enter_loop_var("f".into(), "inner".into(), "inner".into());
404 assert_eq!(expand_vars("$f", false), "inner", "innermost wins");
405 }
406 assert_eq!(expand_vars("$f", false), "outer", "inner popped on drop");
407 }
408 assert_eq!(expand_vars("$f", false), "$f", "all popped");
409 }
410
411 #[test]
412 fn the_guard_restores_on_drop() {
413 {
414 let _g = enter(PathCtx { cwd: Some("/etc".into()), root: Some("/r".into()) });
415 assert_eq!(resolve("x"), "/etc/x");
416 }
417 assert_eq!(resolve("x"), "x", "context cleared after the guard drops");
418 }
419}