1use super::PathSubcommandArguments;
2use nu_engine::command_prelude::*;
3use nu_path::expand_to_real_path;
4use nu_protocol::engine::StateWorkingSet;
5use std::path::Path;
6
7struct Arguments {
8 path: Spanned<String>,
9}
10
11impl PathSubcommandArguments for Arguments {}
12
13#[derive(Clone)]
14pub struct PathRelativeTo;
15
16impl Command for PathRelativeTo {
17 fn name(&self) -> &str {
18 "path relative-to"
19 }
20
21 fn signature(&self) -> Signature {
22 Signature::build("path relative-to")
23 .input_output_types(vec![
24 (Type::String, Type::String),
25 (
26 Type::List(Box::new(Type::String)),
27 Type::List(Box::new(Type::String)),
28 ),
29 ])
30 .required(
31 "path",
32 SyntaxShape::String,
33 "Parent shared with the input path.",
34 )
35 .category(Category::Path)
36 }
37
38 fn description(&self) -> &str {
39 "Express a path as relative to another path."
40 }
41
42 fn extra_description(&self) -> &str {
43 r#"Can be used only when the input and the argument paths are either both
44absolute or both relative. The argument path needs to be a parent of the input
45path."#
46 }
47
48 fn is_const(&self) -> bool {
49 true
50 }
51
52 fn run(
53 &self,
54 engine_state: &EngineState,
55 stack: &mut Stack,
56 call: &Call,
57 input: PipelineData,
58 ) -> Result<PipelineData, ShellError> {
59 let head = call.head;
60 let args = Arguments {
61 path: call.req(engine_state, stack, 0)?,
62 };
63
64 if let PipelineData::Empty = input {
66 return Err(ShellError::PipelineEmpty { dst_span: head });
67 }
68 input.map(
69 move |value| super::operate(&relative_to, &args, value, head),
70 engine_state.signals(),
71 )
72 }
73
74 fn run_const(
75 &self,
76 working_set: &StateWorkingSet,
77 call: &Call,
78 input: PipelineData,
79 ) -> Result<PipelineData, ShellError> {
80 let head = call.head;
81 let args = Arguments {
82 path: call.req_const(working_set, 0)?,
83 };
84
85 if let PipelineData::Empty = input {
87 return Err(ShellError::PipelineEmpty { dst_span: head });
88 }
89 input.map(
90 move |value| super::operate(&relative_to, &args, value, head),
91 working_set.permanent().signals(),
92 )
93 }
94
95 #[cfg(windows)]
96 fn examples(&self) -> Vec<Example<'_>> {
97 vec![
98 Example {
99 description: "Find a relative path from two absolute paths",
100 example: r"'C:\Users\viking' | path relative-to 'C:\Users'",
101 result: Some(Value::test_string(r"viking")),
102 },
103 Example {
104 description: "Find a relative path from absolute paths in list",
105 example: r"[ C:\Users\viking, C:\Users\spam ] | path relative-to C:\Users",
106 result: Some(Value::test_list(vec![
107 Value::test_string("viking"),
108 Value::test_string("spam"),
109 ])),
110 },
111 Example {
112 description: "Find a relative path from two relative paths",
113 example: r"'eggs\bacon\sausage\spam' | path relative-to 'eggs\bacon\sausage'",
114 result: Some(Value::test_string(r"spam")),
115 },
116 ]
117 }
118
119 #[cfg(not(windows))]
120 fn examples(&self) -> Vec<Example<'_>> {
121 vec![
122 Example {
123 description: "Find a relative path from two absolute paths",
124 example: r"'/home/viking' | path relative-to '/home'",
125 result: Some(Value::test_string(r"viking")),
126 },
127 Example {
128 description: "Find a relative path from absolute paths in list",
129 example: r"[ /home/viking, /home/spam ] | path relative-to '/home'",
130 result: Some(Value::test_list(vec![
131 Value::test_string("viking"),
132 Value::test_string("spam"),
133 ])),
134 },
135 Example {
136 description: "Find a relative path from two relative paths",
137 example: r"'eggs/bacon/sausage/spam' | path relative-to 'eggs/bacon/sausage'",
138 result: Some(Value::test_string(r"spam")),
139 },
140 ]
141 }
142}
143
144fn relative_to(path: &Path, span: Span, args: &Arguments) -> Value {
145 let lhs = expand_to_real_path(path);
146 let rhs = expand_to_real_path(&args.path.item);
147
148 match lhs.strip_prefix(&rhs) {
149 Ok(p) => Value::string(p.to_string_lossy(), span),
150 Err(e) => {
151 if is_case_insensitive_filesystem()
153 && let Some(relative_path) = try_case_insensitive_strip_prefix(&lhs, &rhs)
154 {
155 return Value::string(relative_path.to_string_lossy(), span);
156 }
157
158 Value::error(
159 ShellError::CantConvert {
160 to_type: e.to_string(),
161 from_type: "string".into(),
162 span,
163 help: None,
164 },
165 span,
166 )
167 }
168 }
169}
170
171fn is_case_insensitive_filesystem() -> bool {
173 cfg!(any(target_os = "windows", target_os = "macos"))
175}
176
177fn try_case_insensitive_strip_prefix(lhs: &Path, rhs: &Path) -> Option<std::path::PathBuf> {
179 let mut lhs_components = lhs.components();
180 let mut rhs_components = rhs.components();
181
182 loop {
184 match (lhs_components.next(), rhs_components.next()) {
185 (Some(lhs_comp), Some(rhs_comp)) => {
186 match (lhs_comp, rhs_comp) {
187 (
188 std::path::Component::Normal(lhs_name),
189 std::path::Component::Normal(rhs_name),
190 ) => {
191 if lhs_name.to_string_lossy().to_lowercase()
192 != rhs_name.to_string_lossy().to_lowercase()
193 {
194 return None;
195 }
196 }
197 _ if lhs_comp != rhs_comp => {
199 return None;
200 }
201 _ => {}
202 }
203 }
204 (Some(lhs_comp), None) => {
205 let mut result = std::path::PathBuf::new();
208 result.push(lhs_comp);
210 for component in lhs_components {
212 result.push(component);
213 }
214 return Some(result);
215 }
216 (None, Some(_)) => {
217 return None;
219 }
220 (None, None) => {
221 return Some(std::path::PathBuf::new());
223 }
224 }
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 #[test]
233 fn test_examples() {
234 use crate::test_examples;
235
236 test_examples(PathRelativeTo {})
237 }
238
239 #[test]
240 fn test_case_insensitive_filesystem() {
241 use nu_protocol::{Span, Value};
242 use std::path::Path;
243
244 let args = Arguments {
245 path: Spanned {
246 item: "/Etc".to_string(),
247 span: Span::test_data(),
248 },
249 };
250
251 let result = relative_to(Path::new("/etc"), Span::test_data(), &args);
252
253 if is_case_insensitive_filesystem() {
256 match result {
257 Value::String { val, .. } => {
258 assert_eq!(val, "");
259 }
260 _ => panic!("Expected string result on case-insensitive filesystem"),
261 }
262 } else {
263 match result {
264 Value::Error { .. } => {
265 }
267 _ => panic!("Expected error on case-sensitive filesystem"),
268 }
269 }
270 }
271
272 #[test]
273 fn test_case_insensitive_with_subpath() {
274 use nu_protocol::{Span, Value};
275 use std::path::Path;
276
277 let args = Arguments {
278 path: Spanned {
279 item: "/Home/User".to_string(),
280 span: Span::test_data(),
281 },
282 };
283
284 let result = relative_to(Path::new("/home/user/documents"), Span::test_data(), &args);
285
286 if is_case_insensitive_filesystem() {
287 match result {
288 Value::String { val, .. } => {
289 assert_eq!(val, "documents");
290 }
291 _ => panic!("Expected string result on case-insensitive filesystem"),
292 }
293 } else {
294 match result {
295 Value::Error { .. } => {
296 }
298 _ => panic!("Expected error on case-sensitive filesystem"),
299 }
300 }
301 }
302
303 #[test]
304 fn test_truly_different_paths() {
305 use nu_protocol::{Span, Value};
306 use std::path::Path;
307
308 let args = Arguments {
309 path: Spanned {
310 item: "/Different/Path".to_string(),
311 span: Span::test_data(),
312 },
313 };
314
315 let result = relative_to(Path::new("/home/user"), Span::test_data(), &args);
316
317 match result {
319 Value::Error { .. } => {}
320 _ => panic!("Expected error for truly different paths"),
321 }
322 }
323}