Skip to main content

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, generic::GenericError, 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::Generic(
230                                    GenericError::new(
231                                        format!("Error while parsing as {ext}"),
232                                        format!(
233                                            "Could not parse '{}' with `from {}`",
234                                            path.display(),
235                                            ext
236                                        ),
237                                        arg_span,
238                                    )
239                                    .with_help(format!(
240                                        "Check out `help from {}` or `help from` for more options or open raw data with `open --raw '{}'`",
241                                        ext,
242                                        path.display()
243                                    ))
244                                    .with_inner([inner]),
245                                )
246                            })?);
247                        }
248                        None => {
249                            // If no converter was found, add content-type metadata
250                            let content_type = path
251                                .extension()
252                                .map(|ext| ext.to_string_lossy().to_string())
253                                .and_then(|ref s| detect_content_type(s));
254
255                            let stream_with_content_type =
256                                stream.set_metadata(Some(PipelineMetadata {
257                                    data_source: DataSource::FilePath(path.to_path_buf()),
258                                    content_type,
259                                    ..Default::default()
260                                }));
261                            output.push(stream_with_content_type);
262                        }
263                    }
264                }
265            }
266        }
267
268        if output.is_empty() {
269            Ok(PipelineData::empty())
270        } else if output.len() == 1 {
271            Ok(output.remove(0))
272        } else {
273            Ok(output
274                .into_iter()
275                .flatten()
276                .into_pipeline_data(call_span, engine_state.signals().clone()))
277        }
278    }
279
280    fn examples(&self) -> Vec<nu_protocol::Example<'_>> {
281        vec![
282            Example {
283                description: "Open a file, with structure (based on file extension or SQLite database header).",
284                example: "open myfile.json",
285                result: None,
286            },
287            Example {
288                description: "Open a file, as raw bytes.",
289                example: "open myfile.json --raw",
290                result: None,
291            },
292            Example {
293                description: "Open a file, using the input to get filename.",
294                example: "'myfile.txt' | open",
295                result: None,
296            },
297            Example {
298                description: "Open a file, and decode it by the specified encoding.",
299                example: "open myfile.txt --raw | decode utf-8",
300                result: None,
301            },
302            Example {
303                description: "Create a custom `from` parser to open newline-delimited JSON files with `open`.",
304                example: r#"def "from ndjson" [] { from json -o }; open myfile.ndjson"#,
305                result: None,
306            },
307            Example {
308                description: "Show the extensions for which the `open` command will automatically parse.",
309                example: r#"scope commands
310    | where name starts-with "from "
311    | insert extension { get name | str replace -r "^from " "" | $"*.($in)" }
312    | select extension name
313    | rename extension command
314"#,
315                result: None,
316            },
317        ]
318    }
319}
320
321fn permission_denied(dir: impl AsRef<Path>) -> bool {
322    match dir.as_ref().read_dir() {
323        Err(e) => matches!(e.kind(), std::io::ErrorKind::PermissionDenied),
324        Ok(_) => false,
325    }
326}
327
328fn extract_extensions(filename: &str) -> Vec<String> {
329    let parts: Vec<&str> = filename.split('.').collect();
330    let mut extensions: Vec<String> = Vec::new();
331    let mut current_extension = String::new();
332
333    for part in parts.iter().rev() {
334        if current_extension.is_empty() {
335            current_extension.push_str(part);
336        } else {
337            current_extension = format!("{part}.{current_extension}");
338        }
339        extensions.push(current_extension.clone());
340    }
341
342    extensions.pop();
343    extensions.reverse();
344
345    extensions
346}
347
348fn detect_content_type(extension: &str) -> Option<String> {
349    // This will allow the overriding of metadata to be consistent with
350    // the content type
351    match extension {
352        // Per RFC-9512, application/yaml should be used
353        "yaml" | "yml" => Some("application/yaml".to_string()),
354        "nu" => Some("application/x-nuscript".to_string()),
355        "json" | "jsonl" | "ndjson" => Some("application/json".to_string()),
356        "nuon" => Some("application/x-nuon".to_string()),
357        _ => mime_guess::from_ext(extension)
358            .first()
359            .map(|mime| mime.to_string()),
360    }
361}
362
363#[cfg(test)]
364mod test {
365
366    #[test]
367    fn test_content_type() {}
368}