nu_command/filesystem/
open.rs

1#[allow(deprecated)]
2use nu_engine::{command_prelude::*, current_dir, eval_call};
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        #[allow(deprecated)]
74        let cwd = current_dir(engine_state, stack)?;
75        let mut paths = call.rest::<Spanned<NuGlob>>(engine_state, stack, 0)?;
76
77        if paths.is_empty() && !call.has_positional_args(stack, 0) {
78            // try to use path from pipeline input if there were no positional or spread args
79            let (filename, span) = match input {
80                PipelineData::Value(val, ..) => {
81                    let span = val.span();
82                    (val.coerce_into_string()?, span)
83                }
84                _ => {
85                    return Err(ShellError::MissingParameter {
86                        param_name: "needs filename".to_string(),
87                        span: call.head,
88                    });
89                }
90            };
91
92            paths.push(Spanned {
93                item: NuGlob::Expand(filename),
94                span,
95            });
96        }
97
98        let mut output = vec![];
99
100        for mut path in paths {
101            //FIXME: `open` should not have to do this
102            path.item = path.item.strip_ansi_string_unlikely();
103
104            let arg_span = path.span;
105            // let path_no_whitespace = &path.item.trim_end_matches(|x| matches!(x, '\x09'..='\x0d'));
106
107            for path in
108                nu_engine::glob_from(&path, &cwd, call_span, None, engine_state.signals().clone())
109                    .map_err(|err| match err {
110                        ShellError::Io(mut err) => {
111                            err.kind = err.kind.not_found_as(NotFound::File);
112                            err.span = arg_span;
113                            err.into()
114                        }
115                        _ => err,
116                    })?
117                    .1
118            {
119                let path = path?;
120                let path = Path::new(&path);
121
122                if permission_denied(path) {
123                    let err = IoError::new(
124                        shell_error::io::ErrorKind::from_std(std::io::ErrorKind::PermissionDenied),
125                        arg_span,
126                        PathBuf::from(path),
127                    );
128
129                    #[cfg(unix)]
130                    let err = {
131                        let mut err = err;
132                        err.additional_context = Some(
133                            match path.metadata() {
134                                Ok(md) => format!(
135                                    "The permissions of {:o} does not allow access for this user",
136                                    md.permissions().mode() & 0o0777
137                                ),
138                                Err(e) => e.to_string(),
139                            }
140                            .into(),
141                        );
142                        err
143                    };
144
145                    return Err(err.into());
146                } else {
147                    #[cfg(feature = "sqlite")]
148                    if !raw {
149                        let res = SQLiteDatabase::try_from_path(
150                            path,
151                            arg_span,
152                            engine_state.signals().clone(),
153                        )
154                        .map(|db| db.into_value(call.head).into_pipeline_data());
155
156                        if res.is_ok() {
157                            return res;
158                        }
159                    }
160
161                    if path.is_dir() {
162                        // At least under windows this check ensures that we don't get a
163                        // permission denied error on directories
164                        return Err(ShellError::Io(IoError::new(
165                            #[allow(
166                                deprecated,
167                                reason = "we don't have a IsADirectory variant here, so we provide one"
168                            )]
169                            shell_error::io::ErrorKind::from_std(std::io::ErrorKind::IsADirectory),
170                            arg_span,
171                            PathBuf::from(path),
172                        )));
173                    }
174
175                    let file = std::fs::File::open(path)
176                        .map_err(|err| IoError::new(err, arg_span, PathBuf::from(path)))?;
177
178                    // No content_type by default - Is added later if no converter is found
179                    let stream = PipelineData::ByteStream(
180                        ByteStream::file(file, call_span, engine_state.signals().clone()),
181                        Some(PipelineMetadata {
182                            data_source: DataSource::FilePath(path.to_path_buf()),
183                            content_type: None,
184                        }),
185                    );
186
187                    let exts_opt: Option<Vec<String>> = if raw {
188                        None
189                    } else {
190                        let path_str = path
191                            .file_name()
192                            .unwrap_or(std::ffi::OsStr::new(path))
193                            .to_string_lossy()
194                            .to_lowercase();
195                        Some(extract_extensions(path_str.as_str()))
196                    };
197
198                    let converter = exts_opt.and_then(|exts| {
199                        exts.iter().find_map(|ext| {
200                            engine_state
201                                .find_decl(format!("from {}", ext).as_bytes(), &[])
202                                .map(|id| (id, ext.to_string()))
203                        })
204                    });
205
206                    match converter {
207                        Some((converter_id, ext)) => {
208                            let open_call = ast::Call {
209                                decl_id: converter_id,
210                                head: call_span,
211                                arguments: vec![],
212                                parser_info: HashMap::new(),
213                            };
214                            let command_output = if engine_state.is_debugging() {
215                                eval_call::<WithDebug>(engine_state, stack, &open_call, stream)
216                            } else {
217                                eval_call::<WithoutDebug>(engine_state, stack, &open_call, stream)
218                            };
219                            output.push(command_output.map_err(|inner| {
220                                    ShellError::GenericError{
221                                        error: format!("Error while parsing as {ext}"),
222                                        msg: format!("Could not parse '{}' with `from {}`", path.display(), ext),
223                                        span: Some(arg_span),
224                                        help: Some(format!("Check out `help from {}` or `help from` for more options or open raw data with `open --raw '{}'`", ext, path.display())),
225                                        inner: vec![inner],
226                                }
227                                })?);
228                        }
229                        None => {
230                            // If no converter was found, add content-type metadata
231                            let content_type = path
232                                .extension()
233                                .map(|ext| ext.to_string_lossy().to_string())
234                                .and_then(|ref s| detect_content_type(s));
235
236                            let stream_with_content_type =
237                                stream.set_metadata(Some(PipelineMetadata {
238                                    data_source: DataSource::FilePath(path.to_path_buf()),
239                                    content_type,
240                                }));
241                            output.push(stream_with_content_type);
242                        }
243                    }
244                }
245            }
246        }
247
248        if output.is_empty() {
249            Ok(PipelineData::Empty)
250        } else if output.len() == 1 {
251            Ok(output.remove(0))
252        } else {
253            Ok(output
254                .into_iter()
255                .flatten()
256                .into_pipeline_data(call_span, engine_state.signals().clone()))
257        }
258    }
259
260    fn examples(&self) -> Vec<nu_protocol::Example> {
261        vec![
262            Example {
263                description: "Open a file, with structure (based on file extension or SQLite database header)",
264                example: "open myfile.json",
265                result: None,
266            },
267            Example {
268                description: "Open a file, as raw bytes",
269                example: "open myfile.json --raw",
270                result: None,
271            },
272            Example {
273                description: "Open a file, using the input to get filename",
274                example: "'myfile.txt' | open",
275                result: None,
276            },
277            Example {
278                description: "Open a file, and decode it by the specified encoding",
279                example: "open myfile.txt --raw | decode utf-8",
280                result: None,
281            },
282            Example {
283                description: "Create a custom `from` parser to open newline-delimited JSON files with `open`",
284                example: r#"def "from ndjson" [] { from json -o }; open myfile.ndjson"#,
285                result: None,
286            },
287            Example {
288                description: "Show the extensions for which the `open` command will automatically parse",
289                example: r#"scope commands
290    | where name starts-with "from "
291    | insert extension { get name | str replace -r "^from " "" | $"*.($in)" }
292    | select extension name
293    | rename extension command
294"#,
295                result: None,
296            },
297        ]
298    }
299}
300
301fn permission_denied(dir: impl AsRef<Path>) -> bool {
302    match dir.as_ref().read_dir() {
303        Err(e) => matches!(e.kind(), std::io::ErrorKind::PermissionDenied),
304        Ok(_) => false,
305    }
306}
307
308fn extract_extensions(filename: &str) -> Vec<String> {
309    let parts: Vec<&str> = filename.split('.').collect();
310    let mut extensions: Vec<String> = Vec::new();
311    let mut current_extension = String::new();
312
313    for part in parts.iter().rev() {
314        if current_extension.is_empty() {
315            current_extension.push_str(part);
316        } else {
317            current_extension = format!("{}.{}", part, current_extension);
318        }
319        extensions.push(current_extension.clone());
320    }
321
322    extensions.pop();
323    extensions.reverse();
324
325    extensions
326}
327
328fn detect_content_type(extension: &str) -> Option<String> {
329    // This will allow the overriding of metadata to be consistent with
330    // the content type
331    match extension {
332        // Per RFC-9512, application/yaml should be used
333        "yaml" | "yml" => Some("application/yaml".to_string()),
334        "nu" => Some("application/x-nuscript".to_string()),
335        "json" | "jsonl" | "ndjson" => Some("application/json".to_string()),
336        "nuon" => Some("application/x-nuon".to_string()),
337        _ => mime_guess::from_ext(extension)
338            .first()
339            .map(|mime| mime.to_string()),
340    }
341}
342
343#[cfg(test)]
344mod test {
345
346    #[test]
347    fn test_content_type() {}
348}