Skip to main content

yash_builtin/cd/
target.rs

1// This file is part of yash, an extended POSIX shell.
2// Copyright (C) 2023 WATANABE Yuki
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// This program is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12// GNU General Public License for more details.
13//
14// You should have received a copy of the GNU General Public License
15// along with this program.  If not, see <https://www.gnu.org/licenses/>.
16
17//! Part of the cd built-in that computes the target directory path
18
19use super::Command;
20use super::Mode;
21use thiserror::Error;
22use yash_env::Env;
23use yash_env::path::Path;
24use yash_env::path::PathBuf;
25use yash_env::semantics::ExitStatus;
26use yash_env::source::Location;
27use yash_env::source::pretty::{Report, ReportType, Snippet};
28use yash_env::system::Fstat;
29use yash_env::variable::HOME;
30use yash_env::variable::OLDPWD;
31
32/// Indicates how the target directory was resolved.
33#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq)]
34#[non_exhaustive]
35pub enum Origin {
36    /// The target is `$HOME`. (The operand was omitted.)
37    Home,
38    /// The target is `$OLDPWD`. (The operand was `-`.)
39    Oldpwd,
40    /// The target was resolved relative to a directory in `$CDPATH`.
41    Cdpath,
42    /// The target was resolved relative to the current working directory.
43    Literal,
44}
45
46/// Error in computing the target directory
47#[derive(Debug, Clone, Eq, Error, PartialEq)]
48#[non_exhaustive]
49pub enum TargetError {
50    /// The operand is omitted, but `$HOME` is not set or is empty.
51    #[error("$HOME not set")]
52    UnsetHome {
53        /// Location in the source code where the cd built-in is called
54        location: Location,
55    },
56
57    /// The operand is `-`, but `$OLDPWD` is not set or is empty.
58    #[error("$OLDPWD not set")]
59    UnsetOldpwd {
60        /// Location of the `-` operand
61        location: Location,
62    },
63
64    /// Non-existing directory
65    ///
66    /// When the `-L` option is specified, the built-in tries to canonicalize
67    /// path `a/b/../c` to `a/c`. This is only possible if `a/b` exists. This
68    /// error is returned when `a/b` is not a directory.
69    #[error("change target contains non-existing directory component")]
70    NonExistingDirectory {
71        /// Path to the non-existing directory
72        missing: PathBuf,
73        /// Entire path to the target directory
74        target: PathBuf,
75        /// Location in the source code where the target directory is specified
76        location: Location,
77    },
78}
79
80impl TargetError {
81    /// Returns the exit status that corresponds to this error.
82    #[must_use]
83    pub fn exit_status(&self) -> ExitStatus {
84        match self {
85            TargetError::UnsetHome { .. } | TargetError::UnsetOldpwd { .. } => {
86                super::EXIT_STATUS_UNSET_VARIABLE
87            }
88            TargetError::NonExistingDirectory { .. } => super::EXIT_STATUS_CANNOT_CANONICALIZE,
89        }
90    }
91
92    /// Converts this error to a [`Report`].
93    #[must_use]
94    pub fn to_report(&self) -> Report<'_> {
95        use TargetError::*;
96        let (location, label) = match self {
97            UnsetHome { location } => (
98                location,
99                "cd built-in used without operand requires non-empty $HOME".into(),
100            ),
101
102            UnsetOldpwd { location } => (location, "'-' operand requires non-empty $OLDPWD".into()),
103
104            NonExistingDirectory {
105                missing,
106                target: _,
107                location,
108            } => (
109                location,
110                format!("intermediate directory '{}' not found", missing.display()).into(),
111            ),
112        };
113
114        let mut report = Report::new();
115        report.r#type = ReportType::Error;
116        report.title = self.to_string().into();
117        report.snippets = Snippet::with_primary_span(location, label);
118        report
119    }
120}
121
122impl<'a> From<&'a TargetError> for Report<'a> {
123    #[inline]
124    fn from(error: &'a TargetError) -> Self {
125        error.to_report()
126    }
127}
128
129/// Returns the variable value if it is a non-empty scalar.
130fn get_scalar<'a, S>(env: &'a Env<S>, name: &str) -> Option<&'a str> {
131    env.variables
132        .get_scalar(name)
133        .filter(|value| !value.is_empty())
134}
135
136/// Computes the target directory of the cd built-in.
137///
138/// This function implements steps 1 through 8 of the POSIX specification of the
139/// cd built-in. Additionally, this function resolves a `-` operand to
140/// `$OLDPWD`.
141///
142/// The `pwd` parameter should be the current value of `$PWD`. This is used to
143/// resolve a logical path.
144pub fn target<S>(
145    env: &Env<S>,
146    command: &Command,
147    pwd: &str,
148) -> Result<(PathBuf, Origin), TargetError>
149where
150    S: Fstat,
151{
152    // Step 1 & 2: substitute $HOME and $OLDPWD
153    let (mut curpath, mut origin) = match &command.operand {
154        None => {
155            let home = get_scalar(env, HOME).ok_or_else(|| {
156                let builtin = env.stack.current_builtin();
157                let location =
158                    builtin.map_or_else(|| Location::dummy(""), |b| b.name.origin.clone());
159                TargetError::UnsetHome { location }
160            })?;
161            (PathBuf::from(home), Origin::Home)
162        }
163
164        Some(operand) if operand.value == "-" => {
165            let oldpwd = get_scalar(env, OLDPWD).ok_or_else(|| TargetError::UnsetOldpwd {
166                location: operand.origin.clone(),
167            })?;
168            (PathBuf::from(&oldpwd), Origin::Oldpwd)
169        }
170
171        Some(operand) => (PathBuf::from(&operand.value), Origin::Literal),
172    };
173
174    // Step 3 through 6: search $CDPATH
175    if let Some(path) = super::cdpath::search(env, &curpath) {
176        curpath = path;
177        origin = Origin::Cdpath;
178    }
179
180    if command.mode == Mode::Physical {
181        // Step 7-1: return the result
182        return Ok((curpath, origin));
183    }
184
185    // Step 7-2: make the path absolute
186    curpath = Path::new(pwd).join(curpath);
187    // TODO The current Rust implementation joins "//" and "foo" into "/foo"
188    // where "//foo" is expected, but Rust is not yet ported to platforms where
189    // this difference matters. We may need to revisit this when Rust supports
190    // such a platform, notably Cygwin.
191
192    // Step 8: canonicalize the path
193    curpath = super::canonicalize::canonicalize(&env.system, &curpath).map_err(|e| {
194        TargetError::NonExistingDirectory {
195            missing: e.missing,
196            target: curpath,
197            location: {
198                let field = command.operand.as_ref();
199                let field = field.or_else(|| env.stack.current_builtin().map(|b| &b.name));
200                field.map_or_else(|| Location::dummy(""), |f| f.origin.clone())
201            },
202        }
203    })?;
204
205    Ok((curpath, origin))
206    /*
207    // step 1
208    if (operand == NULL) {
209        if (HOME == NULL || HOME == "") {
210            return error;
211        }
212        // step 2
213        operand = HOME;
214    } else if (operand == "-") {
215        operand = OLDPWD;
216    }
217    // step 3 & 4
218    if (!operand.starts_with('/') &&
219            !operand.starts_with_component(".") &&
220            !operand.starts_with_component("..")) {
221        // step 5
222        curpath = resolve_cdpath(operand);
223        if (curpath == NULL) {
224            curpath = operand;
225        }
226    } else {
227        // step 6
228        curpath = operand;
229    }
230    // step 7
231    if (logical) {
232        if (!curpath.starts_with('/')) {
233            curpath = PWD + '/' + curpath;
234        }
235        // step 8
236        curpath = logical_canonicalize(curpath);
237        // step 9
238        curpath = relativize(curpath);
239    }
240    // step 10
241    chdir(curpath);
242    */
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use yash_env::semantics::Field;
249    use yash_env::stack::Builtin;
250    use yash_env::stack::Frame;
251    use yash_env::variable::Scope;
252
253    #[test]
254    fn default_home() {
255        let mut env = Env::new_virtual();
256        let command = Command {
257            mode: Mode::default(),
258            ensure_pwd: false,
259            operand: None,
260        };
261        env.get_or_create_variable(HOME, Scope::Global)
262            .assign("/home/user", None)
263            .unwrap();
264
265        let target = target(&env, &command, "").unwrap();
266        assert_eq!(target, (PathBuf::from("/home/user"), Origin::Home));
267    }
268
269    #[test]
270    fn home_unset() {
271        let mut env = Env::new_virtual();
272        let command = Command {
273            mode: Mode::default(),
274            ensure_pwd: false,
275            operand: None,
276        };
277        let arg0 = Field::dummy("cd");
278        let location = arg0.origin.clone();
279        let env = env.push_frame(Frame::Builtin(Builtin {
280            name: arg0,
281            is_special: false,
282        }));
283
284        let e = target(&env, &command, "").unwrap_err();
285        assert_eq!(e, TargetError::UnsetHome { location });
286    }
287
288    #[test]
289    fn home_empty() {
290        let mut env = Env::new_virtual();
291        let command = Command {
292            mode: Mode::default(),
293            ensure_pwd: false,
294            operand: None,
295        };
296        let arg0 = Field::dummy("cd");
297        let location = arg0.origin.clone();
298        let mut env = env.push_frame(Frame::Builtin(Builtin {
299            name: arg0,
300            is_special: false,
301        }));
302        env.get_or_create_variable(HOME, Scope::Global)
303            .assign("", None)
304            .unwrap();
305
306        let e = target(&env, &command, "/ignored").unwrap_err();
307        assert_eq!(e, TargetError::UnsetHome { location });
308    }
309
310    #[test]
311    fn oldpwd() {
312        let mut env = Env::new_virtual();
313        let command = Command {
314            mode: Mode::default(),
315            ensure_pwd: false,
316            operand: Some(Field::dummy("-")),
317        };
318        env.get_or_create_variable(OLDPWD, Scope::Global)
319            .assign("/old/dir", None)
320            .unwrap();
321
322        let target = target(&env, &command, "/ignored").unwrap();
323        assert_eq!(target, (PathBuf::from("/old/dir"), Origin::Oldpwd));
324    }
325
326    #[test]
327    fn oldpwd_unset() {
328        let env = Env::new_virtual();
329        let operand = Field::dummy("-");
330        let location = operand.origin.clone();
331        let command = Command {
332            mode: Mode::default(),
333            ensure_pwd: false,
334            operand: Some(operand),
335        };
336
337        let e = target(&env, &command, "/ignored").unwrap_err();
338        assert_eq!(e, TargetError::UnsetOldpwd { location });
339    }
340
341    #[test]
342    fn oldpwd_empty() {
343        let mut env = Env::new_virtual();
344        let operand = Field::dummy("-");
345        let location = operand.origin.clone();
346        let command = Command {
347            mode: Mode::default(),
348            ensure_pwd: false,
349            operand: Some(operand),
350        };
351        env.get_or_create_variable(OLDPWD, Scope::Global)
352            .assign("", None)
353            .unwrap();
354
355        let e = target(&env, &command, "/ignored").unwrap_err();
356        assert_eq!(e, TargetError::UnsetOldpwd { location });
357    }
358
359    #[test]
360    fn literal_physical() {
361        let env = Env::new_virtual();
362
363        let result = target(
364            &env,
365            &Command {
366                mode: Mode::Physical,
367                ensure_pwd: false,
368                operand: Some(Field::dummy("foo")),
369            },
370            "/ignored",
371        )
372        .unwrap();
373        assert_eq!(result, (PathBuf::from("foo"), Origin::Literal));
374
375        let result = target(
376            &env,
377            &Command {
378                mode: Mode::Physical,
379                ensure_pwd: false,
380                operand: Some(Field::dummy("foo/bar")),
381            },
382            "/ignored",
383        )
384        .unwrap();
385        assert_eq!(result, (PathBuf::from("foo/bar"), Origin::Literal));
386    }
387
388    #[test]
389    fn literal_logical_absolute() {
390        let env = Env::new_virtual();
391
392        let result = target(
393            &env,
394            &Command {
395                mode: Mode::Logical,
396                ensure_pwd: false,
397                operand: Some(Field::dummy("/foo")),
398            },
399            "/ignored",
400        )
401        .unwrap();
402        assert_eq!(result, (PathBuf::from("/foo"), Origin::Literal));
403
404        let result = target(
405            &env,
406            &Command {
407                mode: Mode::Logical,
408                ensure_pwd: false,
409                operand: Some(Field::dummy("/foo/bar")),
410            },
411            "/ignored",
412        )
413        .unwrap();
414        assert_eq!(result, (PathBuf::from("/foo/bar"), Origin::Literal));
415    }
416
417    #[test]
418    fn literal_logical_relative() {
419        // The relative path is made absolute by prepending the current directory.
420        let env = Env::new_virtual();
421        let command = Command {
422            mode: Mode::Logical,
423            ensure_pwd: false,
424            operand: Some(Field::dummy("foo/bar")),
425        };
426
427        assert_eq!(
428            target(&env, &command, "/current/pwd").unwrap(),
429            (PathBuf::from("/current/pwd/foo/bar"), Origin::Literal)
430        );
431    }
432}