nu_command/filesystem/
open.rs

1use nu_engine::{command_prelude::*, eval_call};
2use nu_path::is_windows_device_path;
3use nu_protocol::{
4    DataSource, NuGlob, PipelineMetadata, ast,
5    debugger::{WithDebug, WithoutDebug},
6    shell_error::{self, io::IoError},
7};
8use std::{
9    collections::HashMap,
10    path::{Path, PathBuf},
11};
12
13#[cfg(feature = "sqlite")]
14use crate::database::SQLiteDatabase;
15
16#[cfg(unix)]
17use std::os::unix::fs::PermissionsExt;
18
19#[derive(Clone)]
20pub struct Open;
21
22impl Command for Open {
23    fn name(&self) -> &str {
24        "open"
25    }
26
27    fn description(&self) -> &str {
28        "Load a file into a cell, converting to table if possible (avoid by appending '--raw')."
29    }
30
31    fn extra_description(&self) -> &str {
32        "Support to automatically parse files with an extension `.xyz` can be provided by a `from xyz` command in scope."
33    }
34
35    fn search_terms(&self) -> Vec<&str> {
36        vec![
37            "load",
38            "read",
39            "load_file",
40            "read_file",
41            "cat",
42            "get-content",
43        ]
44    }
45
46    fn signature(&self) -> nu_protocol::Signature {
47        Signature::build("open")
48            .input_output_types(vec![
49                (Type::Nothing, Type::Any),
50                (Type::String, Type::Any),
51                // FIXME Type::Any input added to disable pipeline input type checking, as run-time checks can raise undesirable type errors
52                // which aren't caught by the parser. see https://github.com/nushell/nushell/pull/14922 for more details
53                (Type::Any, Type::Any),
54            ])
55            .rest(
56                "files",
57                SyntaxShape::OneOf(vec![SyntaxShape::GlobPattern, SyntaxShape::String]),
58                "The file(s) to open.",
59            )
60            .switch("raw", "open file as raw binary", Some('r'))
61            .category(Category::FileSystem)
62    }
63
64    fn run(
65        &self,
66        engine_state: &EngineState,
67        stack: &mut Stack,
68        call: &Call,
69        input: PipelineData,
70    ) -> Result<PipelineData, ShellError> {
71        let raw = call.has_flag(engine_state, stack, "raw")?;
72        let call_span = call.head;
73        let cwd = engine_state.cwd(Some(stack))?.into_std_path_buf();
74        let mut paths = call.rest::<Spanned<NuGlob>>(engine_state, stack, 0)?;
75
76        if paths.is_empty() && !call.has_positional_args(stack, 0) {
77            // try to use path from pipeline input if there were no positional or spread args
78            let (filename, span) = match input {
79                PipelineData::Value(val, ..) => {
80                    let span = val.span();
81                    (val.coerce_into_string()?, span)
82                }
83                _ => {
84                    return Err(ShellError::MissingParameter {
85                        param_name: "needs filename".to_string(),
86                        span: call.head,
87                    });
88                }
89            };
90
91            paths.push(Spanned {
92                item: NuGlob::Expand(filename),
93                span,
94            });
95        }
96
97        let mut output = vec![];
98
99        for mut path in paths {
100            //FIXME: `open` should not have to do this
101            path.item = path.item.strip_ansi_string_unlikely();
102
103            let arg_span = path.span;
104            // let path_no_whitespace = &path.item.trim_end_matches(|x| matches!(x, '\x09'..='\x0d'));
105
106            let matches: Box<dyn Iterator<Item = Result<PathBuf, ShellError>> + Send> =
107                if is_windows_device_path(Path::new(&path.item.to_string())) {
108                    Box::new(vec![Ok(PathBuf::from(path.item.to_string()))].into_iter())
109                } else {
110                    nu_engine::glob_from(
111                        &path,
112                        &cwd,
113                        call_span,
114                        None,
115                        engine_state.signals().clone(),
116                    )
117                    .map_err(|err| match err {
118                        ShellError::Io(mut err) => {
119                            err.kind = err.kind.not_found_as(NotFound::File);
120                            err.span = arg_span;
121                            err.into()
122                        }
123                        _ => err,
124                    })?
125                    .1
126                };
127            for path in matches {
128                let path = path?;
129                let path = Path::new(&path);
130
131                if permission_denied(path) {
132                    let err = IoError::new(
133                        shell_error::io::ErrorKind::from_std(std::io::ErrorKind::PermissionDenied),
134                        arg_span,
135                        PathBuf::from(path),
136                    );
137
138                    #[cfg(unix)]
139                    let err = {
140                        let mut err = err;
141                        err.additional_context = Some(
142                            match path.metadata() {
143                                Ok(md) => format!(
144                                    "The permissions of {:o} does not allow access for this user",
145                                    md.permissions().mode() & 0o0777
146                                ),
147                                Err(e) => e.to_string(),
148                            }
149                            .into(),
150                        );
151                        err
152                    };
153
154                    return Err(err.into());
155                } else {
156                    #[cfg(feature = "sqlite")]
157                    if !raw {
158                        let res = SQLiteDatabase::try_from_path(
159                            path,
160                            arg_span,
161                            engine_state.signals().clone(),
162                        )
163                        .map(|db| db.into_value(call.head).into_pipeline_data());
164
165                        if res.is_ok() {
166                            return res;
167                        }
168                    }
169
170                    if path.is_dir() {
171                        // At least under windows this check ensures that we don't get a
172                        // permission denied error on directories
173                        return Err(ShellError::Io(IoError::new(
174                            #[allow(
175                                deprecated,
176                                reason = "we don't have a IsADirectory variant here, so we provide one"
177                            )]
178                            shell_error::io::ErrorKind::from_std(std::io::ErrorKind::IsADirectory),
179                            arg_span,
180                            PathBuf::from(path),
181                        )));
182                    }
183
184                    let file = std::fs::File::open(path)
185                        .map_err(|err| IoError::new(err, arg_span, PathBuf::from(path)))?;
186
187                    // No content_type by default - Is added later if no converter is found
188                    let stream = PipelineData::byte_stream(
189                        ByteStream::file(file, call_span, engine_state.signals().clone()),
190                        Some(PipelineMetadata {
191                            data_source: DataSource::FilePath(path.to_path_buf()),
192                            ..Default::default()
193                        }),
194                    );
195
196                    let exts_opt: Option<Vec<String>> = if raw {
197                        None
198                    } else {
199                        let path_str = path
200                            .file_name()
201                            .unwrap_or(std::ffi::OsStr::new(path))
202                            .to_string_lossy()
203                            .to_lowercase();
204                        Some(extract_extensions(path_str.as_str()))
205                    };
206
207                    let converter = exts_opt.and_then(|exts| {
208                        exts.iter().find_map(|ext| {
209                            engine_state
210                                .find_decl(format!("from {ext}").as_bytes(), &[])
211                                .map(|id| (id, ext.to_string()))
212                        })
213                    });
214
215                    match converter {
216                        Some((converter_id, ext)) => {
217                            let open_call = ast::Call {
218                                decl_id: converter_id,
219                                head: call_span,
220                                arguments: vec![],
221                                parser_info: HashMap::new(),
222                            };
223                            let command_output = if engine_state.is_debugging() {
224                                eval_call::<WithDebug>(engine_state, stack, &open_call, stream)
225                            } else {
226                                eval_call::<WithoutDebug>(engine_state, stack, &open_call, stream)
227                            };
228                            output.push(command_output.map_err(|inner| {
229                                    ShellError::GenericError{
230                                        error: format!("Error while parsing as {ext}"),
231                                        msg: format!("Could not parse '{}' with `from {}`", path.display(), ext),
232                                        span: Some(arg_span),
233                                        help: Some(format!("Check out `help from {}` or `help from` for more options or open raw data with `open --raw '{}'`", ext, path.display())),
234                                        inner: vec![inner],
235                                }
236                                })?);
237                        }
238                        None => {
239                            // If no converter was found, add content-type metadata
240                            let content_type = path
241                                .extension()
242                                .map(|ext| ext.to_string_lossy().to_string())
243                                .and_then(|ref s| detect_content_type(s));
244
245                            let stream_with_content_type =
246                                stream.set_metadata(Some(PipelineMetadata {
247                                    data_source: DataSource::FilePath(path.to_path_buf()),
248                                    content_type,
249                                    ..Default::default()
250                                }));
251                            output.push(stream_with_content_type);
252                        }
253                    }
254                }
255            }
256        }
257
258        if output.is_empty() {
259            Ok(PipelineData::empty())
260        } else if output.len() == 1 {
261            Ok(output.remove(0))
262        } else {
263            Ok(output
264                .into_iter()
265                .flatten()
266                .into_pipeline_data(call_span, engine_state.signals().clone()))
267        }
268    }
269
270    fn examples(&self) -> Vec<nu_protocol::Example<'_>> {
271        vec![
272            Example {
273                description: "Open a file, with structure (based on file extension or SQLite database header)",
274                example: "open myfile.json",
275                result: None,
276            },
277            Example {
278                description: "Open a file, as raw bytes",
279                example: "open myfile.json --raw",
280                result: None,
281            },
282            Example {
283                description: "Open a file, using the input to get filename",
284                example: "'myfile.txt' | open",
285                result: None,
286            },
287            Example {
288                description: "Open a file, and decode it by the specified encoding",
289                example: "open myfile.txt --raw | decode utf-8",
290                result: None,
291            },
292            Example {
293                description: "Create a custom `from` parser to open newline-delimited JSON files with `open`",
294                example: r#"def "from ndjson" [] { from json -o }; open myfile.ndjson"#,
295                result: None,
296            },
297            Example {
298                description: "Show the extensions for which the `open` command will automatically parse",
299                example: r#"scope commands
300    | where name starts-with "from "
301    | insert extension { get name | str replace -r "^from " "" | $"*.($in)" }
302    | select extension name
303    | rename extension command
304"#,
305                result: None,
306            },
307        ]
308    }
309}
310
311fn permission_denied(dir: impl AsRef<Path>) -> bool {
312    match dir.as_ref().read_dir() {
313        Err(e) => matches!(e.kind(), std::io::ErrorKind::PermissionDenied),
314        Ok(_) => false,
315    }
316}
317
318fn extract_extensions(filename: &str) -> Vec<String> {
319    let parts: Vec<&str> = filename.split('.').collect();
320    let mut extensions: Vec<String> = Vec::new();
321    let mut current_extension = String::new();
322
323    for part in parts.iter().rev() {
324        if current_extension.is_empty() {
325            current_extension.push_str(part);
326        } else {
327            current_extension = format!("{part}.{current_extension}");
328        }
329        extensions.push(current_extension.clone());
330    }
331
332    extensions.pop();
333    extensions.reverse();
334
335    extensions
336}
337
338fn detect_content_type(extension: &str) -> Option<String> {
339    // This will allow the overriding of metadata to be consistent with
340    // the content type
341    match extension {
342        // Per RFC-9512, application/yaml should be used
343        "yaml" | "yml" => Some("application/yaml".to_string()),
344        "nu" => Some("application/x-nuscript".to_string()),
345        "json" | "jsonl" | "ndjson" => Some("application/json".to_string()),
346        "nuon" => Some("application/x-nuon".to_string()),
347        _ => mime_guess::from_ext(extension)
348            .first()
349            .map(|mime| mime.to_string()),
350    }
351}
352
353#[cfg(test)]
354mod test {
355
356    #[test]
357    fn test_content_type() {}
358}