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
85#[must_use]
88pub fn enter_loop_var(name: String, read_repr: String, write_repr: String) -> LoopGuard {
89 LOOP_VARS.with(|v| v.borrow_mut().push(LoopVar { name, read_repr, write_repr }));
90 LoopGuard
91}
92
93pub struct LoopGuard;
95
96impl Drop for LoopGuard {
97 fn drop(&mut self) {
98 LOOP_VARS.with(|v| {
99 v.borrow_mut().pop();
100 });
101 }
102}
103
104thread_local! {
105 static STDIN_REPR: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
106}
107
108#[must_use]
113pub fn enter_stdin_repr(repr: String) -> StdinReprGuard {
114 STDIN_REPR.with(|v| v.borrow_mut().push(repr));
115 StdinReprGuard
116}
117
118pub fn stdin_item_repr() -> Option<String> {
121 STDIN_REPR.with(|v| v.borrow().last().cloned())
122}
123
124pub struct StdinReprGuard;
125
126impl Drop for StdinReprGuard {
127 fn drop(&mut self) {
128 STDIN_REPR.with(|v| {
129 v.borrow_mut().pop();
130 });
131 }
132}
133
134pub fn expand_loop(path: &str, want_write: bool) -> Cow<'_, str> {
139 if !path.contains('$') {
140 return Cow::Borrowed(path);
141 }
142 let replaced = LOOP_VARS.with(|v| {
143 let vars = v.borrow();
144 if vars.is_empty() {
145 None
146 } else {
147 expand_with(path, &vars, want_write)
148 }
149 });
150 replaced.map_or(Cow::Borrowed(path), Cow::Owned)
151}
152
153fn expand_with(path: &str, vars: &[LoopVar], want_write: bool) -> Option<String> {
154 let mut out = String::with_capacity(path.len());
155 let mut rest = path;
156 let mut replaced = false;
157 while let Some(dollar) = rest.find('$') {
158 out.push_str(&rest[..dollar]);
159 let after = &rest[dollar + 1..];
160 match parse_var(after) {
161 Some((name, consumed)) => {
162 if let Some(lv) = vars.iter().rev().find(|v| v.name == name) {
163 out.push_str(if want_write { &lv.write_repr } else { &lv.read_repr });
164 replaced = true;
165 } else {
166 out.push('$');
167 out.push_str(&after[..consumed]);
168 }
169 rest = &after[consumed..];
170 }
171 None => {
172 out.push('$');
173 rest = after;
174 }
175 }
176 }
177 out.push_str(rest);
178 replaced.then_some(out)
179}
180
181fn parse_var(after: &str) -> Option<(&str, usize)> {
184 if let Some(braced) = after.strip_prefix('{') {
185 let close = braced.find('}')?;
186 let name = &braced[..close];
187 is_var_name(name).then_some((name, close + 2)) } else {
189 let len = after.bytes().take_while(|&b| b.is_ascii_alphanumeric() || b == b'_').count();
190 let name = &after[..len];
191 is_var_name(name).then_some((name, len))
192 }
193}
194
195fn is_var_name(s: &str) -> bool {
196 let mut bytes = s.bytes();
197 matches!(bytes.next(), Some(b) if b.is_ascii_alphabetic() || b == b'_')
198 && bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_')
199}
200
201pub fn resolve(path: &str) -> Cow<'_, str> {
211 if path.is_empty() || path.starts_with('~') || path.contains('$') {
213 return Cow::Borrowed(path);
214 }
215 let resolved = CURRENT.with(|c| {
216 let ctx = c.borrow();
217 match (ctx.cwd.as_deref(), ctx.root.as_deref()) {
218 (Some(cwd), Some(root)) if cwd.starts_with('/') && root.starts_with('/') => {
219 let abs = if path.starts_with('/') {
222 lexical_join("/", path)
223 } else {
224 lexical_join(cwd, path)
225 };
226 Some(express_relative_to_root(&abs, root))
227 }
228 _ => None,
229 }
230 });
231 resolved.map_or(Cow::Borrowed(path), Cow::Owned)
232}
233
234pub fn join_cwd(cur: Option<&str>, target: &str) -> Option<String> {
239 if target.starts_with('~') || target.contains('$') {
240 return None; }
242 if target.starts_with('/') {
243 return Some(lexical_join("/", target)); }
245 cur.filter(|c| c.starts_with('/')).map(|c| lexical_join(c, target)) }
247
248fn express_relative_to_root(abs: &str, root: &str) -> String {
253 let root = root.trim_end_matches('/');
254 if abs == root {
255 return ".".to_string(); }
257 match abs.strip_prefix(root) {
258 Some(inside) if inside.starts_with('/') => inside.trim_start_matches('/').to_string(),
259 _ => abs.to_string(),
260 }
261}
262
263fn lexical_join(base: &str, rel: &str) -> String {
266 let mut parts: Vec<&str> = base.split('/').filter(|s| !s.is_empty()).collect();
267 for seg in rel.split('/') {
268 match seg {
269 "" | "." => {}
270 ".." => {
271 parts.pop();
272 }
273 s => parts.push(s),
274 }
275 }
276 format!("/{}", parts.join("/"))
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 #[test]
284 fn no_context_leaves_paths_unchanged() {
285 assert_eq!(resolve("./x"), "./x");
286 assert_eq!(resolve("config"), "config");
287 assert_eq!(resolve("/etc/x"), "/etc/x");
288 }
289
290 #[test]
291 fn relative_inside_the_project_stays_worktree_relative() {
292 let _g = enter(PathCtx { cwd: Some("/home/u/proj/sub".into()), root: Some("/home/u/proj".into()) });
293 assert_eq!(resolve("x"), "sub/x", "cwd under root → root-relative");
294 assert_eq!(resolve("./y"), "sub/y");
295 assert_eq!(resolve("../z"), "z", ".. that stays inside root");
296 }
297
298 #[test]
299 fn relative_outside_the_project_becomes_absolute() {
300 let _g = enter(PathCtx { cwd: Some("/etc".into()), root: Some("/home/u/proj".into()) });
301 assert_eq!(resolve("x"), "/etc/x", "cd /etc → the real target");
302 assert_eq!(resolve("passwd"), "/etc/passwd");
303 assert_eq!(resolve("*"), "/etc/*");
304 }
305
306 #[test]
307 fn dotdot_escaping_the_project_becomes_absolute() {
308 let _g = enter(PathCtx { cwd: Some("/home/u/proj".into()), root: Some("/home/u/proj".into()) });
309 assert_eq!(resolve("../../../etc/x"), "/etc/x");
310 }
311
312 #[test]
313 fn absolute_in_root_becomes_root_relative_outside_stays_absolute() {
314 let _g = enter(PathCtx { cwd: Some("/home/u/proj/sub".into()), root: Some("/home/u/proj".into()) });
315 assert_eq!(resolve("/home/u/proj/main.rs"), "main.rs");
317 assert_eq!(resolve("/home/u/proj/sub/x"), "sub/x");
318 assert_eq!(resolve("/home/u/proj/a/../b"), "b", "normalized in place");
319 assert_eq!(resolve("/home/u/proj"), ".", "the project root itself");
320 assert_eq!(resolve("/usr/bin/x"), "/usr/bin/x");
322 assert_eq!(resolve("/home/u/proj/../../etc/x"), "/home/etc/x", "climbs to /home, still outside root");
323 assert_eq!(resolve("/home/u/proj/../../../etc/x"), "/etc/x", "escapes to /etc via ..");
324 assert_eq!(
325 resolve("/home/u/proj-evil/secret"), "/home/u/proj-evil/secret",
326 "a sibling dir is not confused for inside by bare string prefix",
327 );
328 assert_eq!(resolve("$HOME/x"), "$HOME/x");
330 assert_eq!(resolve("~/x"), "~/x");
331 }
332
333 #[test]
334 fn loop_var_expands_to_its_representative_per_face() {
335 let _g = enter_loop_var("f".into(), "read_item".into(), "write_item".into());
336 assert_eq!(expand_loop("$f", false), "read_item");
337 assert_eq!(expand_loop("$f", true), "write_item");
338 assert_eq!(expand_loop("${f}", false), "read_item");
339 assert_eq!(expand_loop("$f.bak", false), "read_item.bak", "compound suffix");
340 assert_eq!(expand_loop("pre/$f", false), "pre/read_item");
341 assert_eq!(expand_loop("$foo", false), "$foo", "$foo is not $f");
342 assert_eq!(expand_loop("$g", false), "$g", "unbound var untouched");
343 assert_eq!(expand_loop("plain", false), "plain");
344 }
345
346 #[test]
347 fn loop_var_binding_is_scoped_and_nests() {
348 assert_eq!(expand_loop("$f", false), "$f", "no binding");
349 {
350 let _outer = enter_loop_var("f".into(), "outer".into(), "outer".into());
351 {
352 let _inner = enter_loop_var("f".into(), "inner".into(), "inner".into());
353 assert_eq!(expand_loop("$f", false), "inner", "innermost wins");
354 }
355 assert_eq!(expand_loop("$f", false), "outer", "inner popped on drop");
356 }
357 assert_eq!(expand_loop("$f", false), "$f", "all popped");
358 }
359
360 #[test]
361 fn the_guard_restores_on_drop() {
362 {
363 let _g = enter(PathCtx { cwd: Some("/etc".into()), root: Some("/r".into()) });
364 assert_eq!(resolve("x"), "/etc/x");
365 }
366 assert_eq!(resolve("x"), "x", "context cleared after the guard drops");
367 }
368}