From dc9d939c833c09a46c0f6825463d1184e40663f1 Mon Sep 17 00:00:00 2001 From: Kangaxx-0 <85712372+Kangaxx-0@users.noreply.github.com> Date: Sun, 26 Jun 2022 04:53:06 -0700 Subject: [PATCH] Introduce new command - nu check (#5864) * nu check command - 1 * Support stream * Polish code and fix corner case --- crates/nu-command/src/default_context.rs | 1 + crates/nu-command/src/system/mod.rs | 2 + crates/nu-command/src/system/nu_check.rs | 302 ++++++++++ crates/nu-command/tests/commands/mod.rs | 1 + crates/nu-command/tests/commands/nu_check.rs | 552 +++++++++++++++++++ 5 files changed, 858 insertions(+) create mode 100644 crates/nu-command/src/system/nu_check.rs create mode 100644 crates/nu-command/tests/commands/nu_check.rs diff --git a/crates/nu-command/src/default_context.rs b/crates/nu-command/src/default_context.rs index 01d0e151fd..0ecc2e5b0d 100644 --- a/crates/nu-command/src/default_context.rs +++ b/crates/nu-command/src/default_context.rs @@ -158,6 +158,7 @@ pub fn create_default_context(cwd: impl AsRef) -> EngineState { Complete, Exec, External, + NuCheck, Ps, Sys, }; diff --git a/crates/nu-command/src/system/mod.rs b/crates/nu-command/src/system/mod.rs index 3b49075525..127546e8e3 100644 --- a/crates/nu-command/src/system/mod.rs +++ b/crates/nu-command/src/system/mod.rs @@ -1,6 +1,7 @@ mod benchmark; mod complete; mod exec; +mod nu_check; mod ps; mod run_external; mod sys; @@ -9,6 +10,7 @@ mod which_; pub use benchmark::Benchmark; pub use complete::Complete; pub use exec::Exec; +pub use nu_check::NuCheck; pub use ps::Ps; pub use run_external::{External, ExternalCommand}; pub use sys::Sys; diff --git a/crates/nu-command/src/system/nu_check.rs b/crates/nu-command/src/system/nu_check.rs new file mode 100644 index 0000000000..3a77fa729f --- /dev/null +++ b/crates/nu-command/src/system/nu_check.rs @@ -0,0 +1,302 @@ +use nu_engine::{current_dir, CallExt}; +use nu_parser::{parse, parse_module_block, unescape_unquote_string}; +use nu_protocol::ast::Call; +use nu_protocol::engine::{Command, EngineState, Stack, StateWorkingSet}; +use nu_protocol::{ + Category, Example, PipelineData, ShellError, Signature, Span, Spanned, SyntaxShape, Value, +}; + +#[derive(Clone)] +pub struct NuCheck; + +impl Command for NuCheck { + fn name(&self) -> &str { + "nu-check" + } + + fn signature(&self) -> Signature { + Signature::build("nu-check") + .optional("path", SyntaxShape::Filepath, "File path to parse") + .switch("as-module", "Parse content as module", Some('m')) + .switch("debug", "Show error messages", Some('d')) + .category(Category::Strings) + } + + fn usage(&self) -> &str { + "Validate and parse input content" + } + + fn search_terms(&self) -> Vec<&str> { + vec!["syntax", "parse", "debug"] + } + + fn run( + &self, + engine_state: &EngineState, + stack: &mut Stack, + call: &Call, + input: PipelineData, + ) -> Result { + let path: Option> = call.opt(engine_state, stack, 0)?; + let is_module = call.has_flag("as-module"); + let is_debug = call.has_flag("debug"); + let config = engine_state.get_config(); + let mut contents = vec![]; + + // DO NOT ever try to merge the working_set in this command + let mut working_set = StateWorkingSet::new(engine_state); + + match input { + PipelineData::Value(Value::String { val, span }, ..) => { + let contents = Vec::from(val); + if is_module { + parse_module(&mut working_set, None, contents, is_debug, span) + } else { + parse_script(&mut working_set, None, contents, is_debug, span) + } + } + PipelineData::ListStream(stream, ..) => { + let list_stream = stream.into_string("\n", config); + let contents = Vec::from(list_stream); + + if is_module { + parse_module(&mut working_set, None, contents, is_debug, call.head) + } else { + parse_script(&mut working_set, None, contents, is_debug, call.head) + } + } + PipelineData::ExternalStream { + stdout: Some(stream), + .. + } => { + let raw_stream: Vec<_> = stream.stream.into_iter().collect(); + for r in raw_stream { + match r { + Ok(v) => contents.extend(v), + Err(error) => return Err(error), + }; + } + + if is_module { + parse_module(&mut working_set, None, contents, is_debug, call.head) + } else { + parse_script(&mut working_set, None, contents, is_debug, call.head) + } + } + _ => { + if path.is_some() { + let path = match find_path(path, engine_state, stack, call.head) { + Ok(path) => path, + Err(error) => return Err(error), + }; + + let ext: Vec<_> = path.rsplitn(2, '.').collect(); + if ext[0] != "nu" { + return Err(ShellError::GenericError( + "Cannot parse input".to_string(), + "File extension must be the type of .nu".to_string(), + Some(call.head), + None, + Vec::new(), + )); + } + + if is_module { + parse_file_module(path, &mut working_set, call, is_debug) + } else { + parse_file_script(path, &mut working_set, call, is_debug) + } + } else { + Err(ShellError::GenericError( + "Failed to execute command".to_string(), + "Please run 'nu-check --help' for more details".to_string(), + Some(call.head), + None, + Vec::new(), + )) + } + } + } + } + + fn examples(&self) -> Vec { + vec![ + Example { + description: "Parse a input file as script(Default)", + example: "nu-check script.nu", + result: None, + }, + Example { + description: "Parse a input file as module", + example: "nu-check --as-module module.nu", + result: None, + }, + Example { + description: "Parse a input file by showing error message", + example: "nu-check -d script.nu", + result: None, + }, + Example { + description: "Parse an external stream as script by showing error message", + example: "open foo.nu | nu-check -d script.nu", + result: None, + }, + Example { + description: "Parse an internal stream as module by showing error message", + example: "open module.nu | lines | nu-check -d --as-module module.nu", + result: None, + }, + Example { + description: "Parse a string as script", + example: "echo $'two(char nl)lines' | nu-check ", + result: None, + }, + ] + } +} + +fn find_path( + path: Option>, + engine_state: &EngineState, + stack: &mut Stack, + span: Span, +) -> Result { + let cwd = current_dir(engine_state, stack)?; + + let path = match path { + Some(s) => { + let path_no_whitespace = &s.item.trim_end_matches(|x| matches!(x, '\x09'..='\x0d')); + + let path = match nu_path::canonicalize_with(path_no_whitespace, &cwd) { + Ok(p) => { + if !p.is_file() { + return Err(ShellError::GenericError( + "Cannot parse input".to_string(), + "Path is not a file".to_string(), + Some(s.span), + None, + Vec::new(), + )); + } else { + p + } + } + + Err(_) => { + return Err(ShellError::FileNotFound(s.span)); + } + }; + path.to_string_lossy().to_string() + } + None => { + return Err(ShellError::NotFound(span)); + } + }; + Ok(path) +} + +fn parse_module( + working_set: &mut StateWorkingSet, + filename: Option, + contents: Vec, + is_debug: bool, + span: Span, +) -> Result { + let start = working_set.next_span_start(); + working_set.add_file( + filename.unwrap_or_else(|| "empty".to_string()), + contents.as_ref(), + ); + let end = working_set.next_span_start(); + + let new_span = Span::new(start, end); + let (_, _, err) = parse_module_block(working_set, new_span, &[]); + + if err.is_some() { + if is_debug { + let msg = format!( + r#"Found : {}"#, + err.expect("Unable to parse content as module") + ); + Err(ShellError::GenericError( + "Failed to parse content".to_string(), + msg, + Some(span), + Some("If the content is intended to be a script, please try to remove `--as-module` flag ".to_string()), + Vec::new(), + )) + } else { + Ok(PipelineData::Value(Value::boolean(false, new_span), None)) + } + } else { + Ok(PipelineData::Value(Value::boolean(true, new_span), None)) + } +} + +fn parse_script( + working_set: &mut StateWorkingSet, + filename: Option<&str>, + contents: Vec, + is_debug: bool, + span: Span, +) -> Result { + let (_, err) = parse(working_set, filename, &contents, false, &[]); + if err.is_some() { + let msg = format!(r#"Found : {}"#, err.expect("Unable to parse content")); + if is_debug { + Err(ShellError::GenericError( + "Failed to parse content".to_string(), + msg, + Some(span), + Some("If the content is intended to be a module, please consider flag of `--as-module` ".to_string()), + Vec::new(), + )) + } else { + Ok(PipelineData::Value(Value::boolean(false, span), None)) + } + } else { + Ok(PipelineData::Value(Value::boolean(true, span), None)) + } +} + +fn parse_file_script( + path: String, + working_set: &mut StateWorkingSet, + call: &Call, + is_debug: bool, +) -> Result { + let (filename, err) = unescape_unquote_string(path.as_bytes(), call.head); + if err.is_none() { + if let Ok(contents) = std::fs::read(&path) { + parse_script( + working_set, + Some(filename.as_str()), + contents, + is_debug, + call.head, + ) + } else { + Err(ShellError::IOError("Can not read path".to_string())) + } + } else { + Err(ShellError::NotFound(call.head)) + } +} + +fn parse_file_module( + path: String, + working_set: &mut StateWorkingSet, + call: &Call, + is_debug: bool, +) -> Result { + let (filename, err) = unescape_unquote_string(path.as_bytes(), call.head); + if err.is_none() { + if let Ok(contents) = std::fs::read(path) { + parse_module(working_set, Some(filename), contents, is_debug, call.head) + } else { + Err(ShellError::IOError("Can not read path".to_string())) + } + } else { + Err(ShellError::NotFound(call.head)) + } +} diff --git a/crates/nu-command/tests/commands/mod.rs b/crates/nu-command/tests/commands/mod.rs index e03fc0a64d..73bf0c9920 100644 --- a/crates/nu-command/tests/commands/mod.rs +++ b/crates/nu-command/tests/commands/mod.rs @@ -38,6 +38,7 @@ mod merge; mod mkdir; mod move_; mod network; +mod nu_check; mod open; mod parse; mod path; diff --git a/crates/nu-command/tests/commands/nu_check.rs b/crates/nu-command/tests/commands/nu_check.rs new file mode 100644 index 0000000000..831a4066cd --- /dev/null +++ b/crates/nu-command/tests/commands/nu_check.rs @@ -0,0 +1,552 @@ +use nu_test_support::fs::Stub::FileWithContentToBeTrimmed; +use nu_test_support::playground::Playground; +use nu_test_support::{nu, pipeline}; + +#[test] +fn parse_script_success() { + Playground::setup("nu_check_test_1", |dirs, sandbox| { + sandbox.with_files(vec![FileWithContentToBeTrimmed( + "script.nu", + r#" + greet "world" + + def greet [name] { + echo "hello" $name + } + "#, + )]); + + let actual = nu!( + cwd: dirs.test(), pipeline( + r#" + nu-check script.nu + "# + )); + + assert!(actual.err.is_empty()); + }) +} + +#[test] +fn parse_script_with_wrong_type() { + Playground::setup("nu_check_test_2", |dirs, sandbox| { + sandbox.with_files(vec![FileWithContentToBeTrimmed( + "script.nu", + r#" + greet "world" + + def greet [name] { + echo "hello" $name + } + "#, + )]); + + let actual = nu!( + cwd: dirs.test(), pipeline( + r#" + nu-check -d --as-module script.nu + "# + )); + + assert!(actual.err.contains("Failed to parse content")); + }) +} +#[test] +fn parse_script_failure() { + Playground::setup("nu_check_test_3", |dirs, sandbox| { + sandbox.with_files(vec![FileWithContentToBeTrimmed( + "script.nu", + r#" + greet "world" + + def greet [name { + echo "hello" $name + } + "#, + )]); + + let actual = nu!( + cwd: dirs.test(), pipeline( + r#" + nu-check -d script.nu + "# + )); + + assert!(actual.err.contains("Unexpected end of code")); + }) +} + +#[test] +fn parse_module_success() { + Playground::setup("nu_check_test_4", |dirs, sandbox| { + sandbox.with_files(vec![FileWithContentToBeTrimmed( + "foo.nu", + r#" + # foo.nu + + export def hello [name: string] { + $"hello ($name)!" + } + + export def hi [where: string] { + $"hi ($where)!" + } + "#, + )]); + + let actual = nu!( + cwd: dirs.test(), pipeline( + r#" + nu-check --as-module foo.nu + "# + )); + + assert!(actual.err.is_empty()); + }) +} + +#[test] +fn parse_module_with_wrong_type() { + Playground::setup("nu_check_test_5", |dirs, sandbox| { + sandbox.with_files(vec![FileWithContentToBeTrimmed( + "foo.nu", + r#" + # foo.nu + + export def hello [name: string { + $"hello ($name)!" + } + + export def hi [where: string] { + $"hi ($where)!" + } + "#, + )]); + + let actual = nu!( + cwd: dirs.test(), pipeline( + r#" + nu-check -d foo.nu + "# + )); + + assert!(actual.err.contains("Failed to parse content")); + }) +} +#[test] +fn parse_module_failure() { + Playground::setup("nu_check_test_6", |dirs, sandbox| { + sandbox.with_files(vec![FileWithContentToBeTrimmed( + "foo.nu", + r#" + # foo.nu + + export def hello [name: string { + $"hello ($name)!" + } + + export def hi [where: string] { + $"hi ($where)!" + } + "#, + )]); + + let actual = nu!( + cwd: dirs.test(), pipeline( + r#" + nu-check -d --as-module foo.nu + "# + )); + + assert!(actual.err.contains("Unexpected end of code")); + }) +} + +#[test] +fn file_not_exist() { + Playground::setup("nu_check_test_7", |dirs, _sandbox| { + let actual = nu!( + cwd: dirs.test(), pipeline( + r#" + nu-check --as-module foo.nu + "# + )); + + assert!(actual.err.contains("file not found")); + }) +} + +#[test] +fn parse_unsupported_file() { + Playground::setup("nu_check_test_8", |dirs, sandbox| { + sandbox.with_files(vec![FileWithContentToBeTrimmed( + "foo.txt", + r#" + # foo.nu + + export def hello [name: string { + $"hello ($name)!" + } + + export def hi [where: string] { + $"hi ($where)!" + } + "#, + )]); + + let actual = nu!( + cwd: dirs.test(), pipeline( + r#" + nu-check --as-module foo.txt + "# + )); + + assert!(actual + .err + .contains("File extension must be the type of .nu")); + }) +} +#[test] +fn parse_dir_failure() { + Playground::setup("nu_check_test_9", |dirs, _sandbox| { + let actual = nu!( + cwd: dirs.test(), pipeline( + r#" + nu-check --as-module ~ + "# + )); + + assert!(actual.err.contains("Path is not a file")); + }) +} + +#[test] +fn parse_module_success_2() { + Playground::setup("nu_check_test_10", |dirs, sandbox| { + sandbox.with_files(vec![FileWithContentToBeTrimmed( + "foo.nu", + r#" + # foo.nu + + export env MYNAME { "Arthur, King of the Britons" } + "#, + )]); + + let actual = nu!( + cwd: dirs.test(), pipeline( + r#" + nu-check --as-module foo.nu + "# + )); + + assert!(actual.err.is_empty()); + }) +} + +#[test] +fn parse_script_success_with_raw_stream() { + Playground::setup("nu_check_test_11", |dirs, sandbox| { + sandbox.with_files(vec![FileWithContentToBeTrimmed( + "script.nu", + r#" + greet "world" + + def greet [name] { + echo "hello" $name + } + "#, + )]); + + let actual = nu!( + cwd: dirs.test(), pipeline( + r#" + open script.nu | nu-check + "# + )); + + assert!(actual.err.is_empty()); + }) +} + +#[test] +fn parse_module_success_with_raw_stream() { + Playground::setup("nu_check_test_12", |dirs, sandbox| { + sandbox.with_files(vec![FileWithContentToBeTrimmed( + "foo.nu", + r#" + # foo.nu + + export def hello [name: string] { + $"hello ($name)!" + } + + export def hi [where: string] { + $"hi ($where)!" + } + "#, + )]); + + let actual = nu!( + cwd: dirs.test(), pipeline( + r#" + open foo.nu | nu-check --as-module + "# + )); + + assert!(actual.err.is_empty()); + }) +} + +#[test] +fn parse_string_as_script_success() { + Playground::setup("nu_check_test_13", |dirs, _sandbox| { + let actual = nu!( + cwd: dirs.test(), pipeline( + r#" + echo $'two(char nl)lines' | nu-check + "# + )); + + assert!(actual.err.is_empty()); + }) +} + +#[test] +fn parse_string_as_script() { + Playground::setup("nu_check_test_14", |dirs, _sandbox| { + let actual = nu!( + cwd: dirs.test(), pipeline( + r#" + echo $'two(char nl)lines' | nu-check -d --as-module + "# + )); + + println!("the out put is {}", actual.err); + assert!(actual.err.contains("Failed to parse content")); + }) +} + +#[test] +fn parse_module_success_with_internal_stream() { + Playground::setup("nu_check_test_15", |dirs, sandbox| { + sandbox.with_files(vec![FileWithContentToBeTrimmed( + "foo.nu", + r#" + # foo.nu + + export def hello [name: string] { + $"hello ($name)!" + } + + export def hi [where: string] { + $"hi ($where)!" + } + "#, + )]); + + let actual = nu!( + cwd: dirs.test(), pipeline( + r#" + open foo.nu | lines | nu-check --as-module + "# + )); + + assert!(actual.err.is_empty()); + }) +} + +#[test] +fn parse_script_success_with_complex_internal_stream() { + Playground::setup("nu_check_test_16", |dirs, sandbox| { + sandbox.with_files(vec![FileWithContentToBeTrimmed( + "grep.nu", + r#" + #grep for nu + def grep-nu [ + search #search term + entrada? #file or pipe + # + #Examples + #grep-nu search file.txt + #ls **/* | some_filter | grep-nu search + #open file.txt | grep-nu search + ] { + if ($entrada | empty?) { + if ($in | column? name) { + grep -ihHn $search ($in | get name) + } else { + ($in | into string) | grep -ihHn $search + } + } else { + grep -ihHn $search $entrada + } + | lines + | parse "{file}:{line}:{match}" + | str trim + | update match {|f| + $f.match + | nu-highlight + } + | rename "source file" "line number" + } + + "#, + )]); + + let actual = nu!( + cwd: dirs.test(), pipeline( + r#" + open grep.nu | lines | nu-check + "# + )); + + assert!(actual.err.is_empty()); + }) +} + +#[test] +fn parse_script_failure_with_complex_internal_stream() { + Playground::setup("nu_check_test_17", |dirs, sandbox| { + sandbox.with_files(vec![FileWithContentToBeTrimmed( + "grep.nu", + r#" + #grep for nu + def grep-nu [ + search #search term + entrada? #file or pipe + # + #Examples + #grep-nu search file.txt + #ls **/* | some_filter | grep-nu search + #open file.txt | grep-nu search + ] + if ($entrada | empty?) { + if ($in | column? name) { + grep -ihHn $search ($in | get name) + } else { + ($in | into string) | grep -ihHn $search + } + } else { + grep -ihHn $search $entrada + } + | lines + | parse "{file}:{line}:{match}" + | str trim + | update match {|f| + $f.match + | nu-highlight + } + | rename "source file" "line number" + } + + "#, + )]); + + let actual = nu!( + cwd: dirs.test(), pipeline( + r#" + open grep.nu | lines | nu-check + "# + )); + + assert_eq!(actual.out, "false".to_string()); + }) +} + +#[test] +fn parse_script_success_with_complex_external_stream() { + Playground::setup("nu_check_test_18", |dirs, sandbox| { + sandbox.with_files(vec![FileWithContentToBeTrimmed( + "grep.nu", + r#" + #grep for nu + def grep-nu [ + search #search term + entrada? #file or pipe + # + #Examples + #grep-nu search file.txt + #ls **/* | some_filter | grep-nu search + #open file.txt | grep-nu search + ] { + if ($entrada | empty?) { + if ($in | column? name) { + grep -ihHn $search ($in | get name) + } else { + ($in | into string) | grep -ihHn $search + } + } else { + grep -ihHn $search $entrada + } + | lines + | parse "{file}:{line}:{match}" + | str trim + | update match {|f| + $f.match + | nu-highlight + } + | rename "source file" "line number" + } + + "#, + )]); + + let actual = nu!( + cwd: dirs.test(), pipeline( + r#" + open grep.nu | nu-check + "# + )); + + assert!(actual.err.is_empty()); + }) +} + +#[test] +fn parse_module_success_with_complex_external_stream() { + Playground::setup("nu_check_test_19", |dirs, sandbox| { + sandbox.with_files(vec![FileWithContentToBeTrimmed( + "grep.nu", + r#" + #grep for nu + def grep-nu [ + search #search term + entrada? #file or pipe + # + #Examples + #grep-nu search file.txt + #ls **/* | some_filter | grep-nu search + #open file.txt | grep-nu search + ] { + if ($entrada | empty?) { + if ($in | column? name) { + grep -ihHn $search ($in | get name) + } else { + ($in | into string) | grep -ihHn $search + } + } else { + grep -ihHn $search $entrada + } + | lines + | parse "{file}:{line}:{match}" + | str trim + | update match {|f| + $f.match + | nu-highlight + } + | rename "source file" "line number" + } + + "#, + )]); + + let actual = nu!( + cwd: dirs.test(), pipeline( + r#" + open grep.nu | nu-check -d --as-module + "# + )); + + assert!(actual.err.is_empty()); + }) +}