From a635955df525482a08ebe06eae76c1353475ca24 Mon Sep 17 00:00:00 2001 From: Darren Schroeder <343840+fdncred@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:07:20 -0500 Subject: [PATCH] feat: enhance formatting options and add tests for margin behavior and alias invocation Co-authored-by: Copilot --- src/config.rs | 29 ++- src/formatting/blocks.rs | 6 +- src/formatting/calls.rs | 241 +++++++++++++++++- src/formatting/expressions.rs | 30 ++- ...n_one_sets_single_blank_line_issue169.nuon | 5 + ...in_zero_allows_no_blank_line_issue169.nuon | 5 + ...oes_not_duplicate_expanded_rhs_issue171.nu | 4 + ...gin_one_sets_single_blank_line_issue169.nu | 3 + ...rgin_zero_allows_no_blank_line_issue169.nu | 2 + ..._required_subexpression_parens_issue172.nu | 7 + ...oes_not_duplicate_expanded_rhs_issue171.nu | 4 + ...gin_one_sets_single_blank_line_issue169.nu | 2 + ...rgin_zero_allows_no_blank_line_issue169.nu | 2 + ..._required_subexpression_parens_issue172.nu | 7 + tests/ground_truth.rs | 46 +++- tests/main.rs | 88 +++++++ 16 files changed, 461 insertions(+), 20 deletions(-) create mode 100644 tests/fixtures/config/margin_one_sets_single_blank_line_issue169.nuon create mode 100644 tests/fixtures/config/margin_zero_allows_no_blank_line_issue169.nuon create mode 100644 tests/fixtures/expected/alias_invocation_in_def_does_not_duplicate_expanded_rhs_issue171.nu create mode 100644 tests/fixtures/expected/margin_one_sets_single_blank_line_issue169.nu create mode 100644 tests/fixtures/expected/margin_zero_allows_no_blank_line_issue169.nu create mode 100644 tests/fixtures/expected/unary_not_condition_keeps_required_subexpression_parens_issue172.nu create mode 100644 tests/fixtures/input/alias_invocation_in_def_does_not_duplicate_expanded_rhs_issue171.nu create mode 100644 tests/fixtures/input/margin_one_sets_single_blank_line_issue169.nu create mode 100644 tests/fixtures/input/margin_zero_allows_no_blank_line_issue169.nu create mode 100644 tests/fixtures/input/unary_not_condition_keeps_required_subexpression_parens_issue172.nu diff --git a/src/config.rs b/src/config.rs index 1e54a19..bc481d1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -11,6 +11,7 @@ pub struct Config { pub indent: usize, pub line_length: usize, pub margin: usize, + pub margin_is_explicit: bool, pub excludes: Vec, } @@ -20,6 +21,7 @@ impl Default for Config { indent: 4, line_length: 80, margin: 1, + margin_is_explicit: false, excludes: Vec::new(), } } @@ -31,6 +33,7 @@ impl Config { indent: tab_spaces, line_length: max_width, margin, + margin_is_explicit: true, excludes: Vec::new(), } } @@ -54,7 +57,10 @@ impl TryFrom for Config { match key.as_str() { "indent" => config.indent = parse_positive_int(key, value)?, "line_length" => config.line_length = parse_positive_int(key, value)?, - "margin" => config.margin = parse_positive_int(key, value)?, + "margin" => { + config.margin = parse_non_negative_int(key, value)?; + config.margin_is_explicit = true; + } "exclude" => config.excludes = parse_string_list(value)?, unknown => return Err(ConfigError::UnknownOption(unknown.to_string())), } @@ -85,6 +91,27 @@ fn parse_positive_int(key: &str, value: &Value) -> Result { Ok(*val as usize) } +/// Parse a value as a non-negative integer (usize) +fn parse_non_negative_int(key: &str, value: &Value) -> Result { + let Value::Int { val, .. } = value else { + return Err(ConfigError::InvalidOptionType( + key.to_string(), + value.get_type().to_string(), + "number", + )); + }; + + if *val < 0 { + return Err(ConfigError::InvalidOptionValue( + key.to_string(), + val.to_string(), + "a non-negative number", + )); + } + + Ok(*val as usize) +} + /// Parse a value as a list of strings fn parse_string_list(value: &Value) -> Result, ConfigError> { let Value::List { vals, .. } = value else { diff --git a/src/formatting/blocks.rs b/src/formatting/blocks.rs index c5660e7..0f664f6 100644 --- a/src/formatting/blocks.rs +++ b/src/formatting/blocks.rs @@ -59,8 +59,8 @@ impl<'a> Formatter<'a> { /// Decide how many newline characters to emit between adjacent pipelines. /// - /// At top-level this respects `margin` while preserving author-intent groups - /// for `margin = 1`, and keeps adjacent `use` statements compact. + /// At top-level this respects `margin` while keeping adjacent `use` + /// statements and same-family `let`/`const` groups compact. fn separator_newlines_between_top_level_pipelines( &self, current: &nu_protocol::ast::Pipeline, @@ -98,7 +98,7 @@ impl<'a> Formatter<'a> { } } - if self.config.margin == 1 { + if self.config.margin == 1 && !self.config.margin_is_explicit { if self.has_blank_line_between_pipelines(current, next) { return 2; } diff --git a/src/formatting/calls.rs b/src/formatting/calls.rs index e103a07..0708368 100644 --- a/src/formatting/calls.rs +++ b/src/formatting/calls.rs @@ -32,6 +32,7 @@ impl<'a> Formatter<'a> { let decl = self.working_set.get_decl(call.decl_id); let decl_name = decl.name(); let cmd_type = Self::classify_command(decl_name); + let head_text = self.call_head_text(call); if self.should_wrap_call_multiline(call, &cmd_type) { self.format_wrapped_call(call); @@ -49,7 +50,18 @@ impl<'a> Formatter<'a> { } if matches!(cmd_type, CommandType::Alias) { - self.format_alias_call(call); + if self.call_head_matches_alias_decl_command(call) { + self.format_alias_call(call); + } else { + self.format_alias_invocation_call(call); + } + return; + } + + if matches!(cmd_type, CommandType::Regular) + && head_text.as_deref().is_some_and(|head| head != decl_name) + { + self.format_alias_invocation_call(call); return; } @@ -58,9 +70,24 @@ impl<'a> Formatter<'a> { return; } + let preserve_not_subexpr_parens = self.conditional_context_depth > 0 && decl_name == "not"; + + if preserve_not_subexpr_parens { + self.preserve_subexpr_parens_depth += 1; + } + for arg in &call.arguments { + if matches!(cmd_type, CommandType::Regular) + && !self.argument_belongs_to_call_source(call, arg) + { + continue; + } self.format_call_argument(arg, &cmd_type); } + + if preserve_not_subexpr_parens { + self.preserve_subexpr_parens_depth -= 1; + } } /// Decide if a call should be emitted as a parenthesized multiline call. @@ -69,23 +96,32 @@ impl<'a> Formatter<'a> { call: &nu_protocol::ast::Call, cmd_type: &CommandType, ) -> bool { - if !matches!(cmd_type, CommandType::Regular) || call.arguments.len() < 3 { + if !matches!(cmd_type, CommandType::Regular) { return false; } - if !call.arguments.iter().all(|arg| { + let source_args: Vec<&Argument> = call + .arguments + .iter() + .filter(|arg| self.argument_belongs_to_call_source(call, arg)) + .collect(); + + if source_args.len() < 3 { + return false; + } + + if !source_args.iter().all(|arg| { matches!( - arg, + *arg, Argument::Positional(_) | Argument::Unknown(_) | Argument::Spread(_) ) }) { return false; } - let end = call - .arguments + let end = source_args .iter() - .map(|arg| match arg { + .map(|arg| match *arg { Argument::Positional(expr) | Argument::Unknown(expr) | Argument::Spread(expr) => { expr.span.end } @@ -113,6 +149,12 @@ impl<'a> Formatter<'a> { /// /// `(cmd\n arg1\n arg2\n)` fn format_wrapped_call(&mut self, call: &nu_protocol::ast::Call) { + let source_args: Vec<&Argument> = call + .arguments + .iter() + .filter(|arg| self.argument_belongs_to_call_source(call, arg)) + .collect(); + self.write("("); if call.head.end != 0 { self.write_span(call.head); @@ -120,7 +162,7 @@ impl<'a> Formatter<'a> { self.newline(); self.indent_level += 1; - for arg in &call.arguments { + for arg in source_args { self.write_indent(); match arg { Argument::Positional(expr) | Argument::Unknown(expr) => { @@ -143,6 +185,61 @@ impl<'a> Formatter<'a> { self.write(")"); } + fn call_head_text(&self, call: &nu_protocol::ast::Call) -> Option { + if call.head.end <= call.head.start || call.head.end > self.source.len() { + return None; + } + + Some( + String::from_utf8_lossy(&self.source[call.head.start..call.head.end]) + .trim() + .to_string(), + ) + } + + fn call_head_matches_alias_decl_command(&self, call: &nu_protocol::ast::Call) -> bool { + self.call_head_text(call) + .as_deref() + .is_some_and(|head| ALIAS_COMMANDS.contains(&head)) + } + + fn format_alias_invocation_call(&mut self, call: &nu_protocol::ast::Call) { + let call_end = call + .arguments + .iter() + .map(|arg| match arg { + Argument::Positional(expr) | Argument::Unknown(expr) | Argument::Spread(expr) => { + expr.span.end + } + Argument::Named(named) => named + .2 + .as_ref() + .map_or(named.0.span.end, |value| value.span.end), + }) + .max() + .unwrap_or(call.head.end) + .min(self.source.len()); + + if call.head.end < call_end { + self.write_bytes(&self.source[call.head.end..call_end]); + } + } + + fn argument_belongs_to_call_source( + &self, + call: &nu_protocol::ast::Call, + arg: &Argument, + ) -> bool { + let span_start = match arg { + Argument::Positional(expr) | Argument::Unknown(expr) | Argument::Spread(expr) => { + expr.span.start + } + Argument::Named(named) => named.0.span.start, + }; + + span_start >= call.head.start + } + /// Format `let`/`mut`/`const` calls while preserving explicit type annotations. pub(super) fn format_let_call(&mut self, call: &nu_protocol::ast::Call) { let positional: Vec<&Expression> = call @@ -431,6 +528,10 @@ impl<'a> Formatter<'a> { } CommandType::Let => self.format_let_argument(positional), CommandType::Regular => { + if self.try_format_empty_braced_regular_argument(positional) { + return; + } + if !self.try_format_closure_like_span(positional.span) { self.format_expression(positional); } @@ -528,6 +629,27 @@ impl<'a> Formatter<'a> { self.write("^"); } self.format_expression(head); + + let tail_end = args + .iter() + .map(|arg| match arg { + ExternalArgument::Regular(arg_expr) | ExternalArgument::Spread(arg_expr) => { + arg_expr.span.end + } + }) + .max() + .unwrap_or(head.span.end) + .min(self.source.len()); + + if head.span.end < tail_end + && self.external_call_tail_matches_arg_suffix(head.span.end, tail_end, args) + { + // Keep the authored tail only when the parser has prepended alias-expanded + // arguments ahead of the user-written suffix. + self.write_bytes(&self.source[head.span.end..tail_end]); + return; + } + for arg in args { self.space(); match arg { @@ -540,6 +662,78 @@ impl<'a> Formatter<'a> { } } + fn external_call_tail_matches_arg_suffix( + &self, + tail_start: usize, + tail_end: usize, + args: &[ExternalArgument], + ) -> bool { + let source_tokens = self.tokenize_source_words(&self.source[tail_start..tail_end]); + let arg_tokens: Vec> = args + .iter() + .map(|arg| match arg { + ExternalArgument::Regular(expr) => { + self.source[expr.span.start..expr.span.end].to_vec() + } + ExternalArgument::Spread(expr) => { + let mut token = b"...".to_vec(); + token.extend_from_slice(&self.source[expr.span.start..expr.span.end]); + token + } + }) + .collect(); + + if source_tokens.is_empty() || source_tokens.len() >= arg_tokens.len() { + return false; + } + + let suffix_start = arg_tokens.len() - source_tokens.len(); + arg_tokens[suffix_start..] == source_tokens + } + + fn tokenize_source_words(&self, bytes: &[u8]) -> Vec> { + let mut tokens = Vec::new(); + let mut current = Vec::new(); + let mut in_string: Option = None; + let mut escaped = false; + + for &byte in bytes { + if let Some(quote) = in_string { + current.push(byte); + if escaped { + escaped = false; + continue; + } + if byte == b'\\' { + escaped = true; + continue; + } + if byte == quote { + in_string = None; + } + continue; + } + + if byte.is_ascii_whitespace() { + if !current.is_empty() { + tokens.push(std::mem::take(&mut current)); + } + continue; + } + + if byte == b'\'' || byte == b'"' { + in_string = Some(byte); + } + current.push(byte); + } + + if !current.is_empty() { + tokens.push(current); + } + + tokens + } + fn try_write_redundant_parenthesized_pipeline_rhs(&mut self, rhs: &Expression) -> bool { let raw = self.get_span_content(rhs.span); let trimmed = raw.trim_ascii(); @@ -567,6 +761,37 @@ impl<'a> Formatter<'a> { true } + fn try_format_empty_braced_regular_argument(&mut self, positional: &Expression) -> bool { + if !matches!(positional.expr, Expr::Block(_) | Expr::Closure(_)) { + return false; + } + + if positional.span.end <= positional.span.start + 1 + || positional.span.end > self.source.len() + { + return false; + } + + let raw = &self.source[positional.span.start..positional.span.end]; + if !raw.starts_with(b"{") || !raw.ends_with(b"}") { + return false; + } + + if !raw[1..raw.len() - 1] + .iter() + .all(|b| b.is_ascii_whitespace()) + { + return false; + } + + if self.has_comments_in_span(positional.span.start, positional.span.end) { + return false; + } + + self.write("{}"); + true + } + fn try_format_closure_like_span(&mut self, span: nu_protocol::Span) -> bool { if span.end <= span.start + 2 || span.end > self.source.len() { return false; diff --git a/src/formatting/expressions.rs b/src/formatting/expressions.rs index 71c48f6..162c42a 100644 --- a/src/formatting/expressions.rs +++ b/src/formatting/expressions.rs @@ -73,7 +73,13 @@ impl<'a> Formatter<'a> { Expr::BinaryOp(lhs, op, rhs) => self.format_binary_op(lhs, op, rhs), Expr::UnaryNot(inner) => { self.write("not "); - self.format_expression(inner); + if let Expr::Subexpression(block_id) = &inner.expr { + self.preserve_subexpr_parens_depth += 1; + self.format_subexpression(*block_id, inner.span); + self.preserve_subexpr_parens_depth -= 1; + } else { + self.format_expression(inner); + } } Expr::Block(block_id) => { @@ -249,7 +255,7 @@ impl<'a> Formatter<'a> { let can_drop_parens = block.pipelines.len() == 1 && block.pipelines[0].elements.len() == 1; - if can_drop_parens { + if can_drop_parens && !self.subexpr_preceded_by_not(span.start) { self.format_block(block); return; } @@ -311,6 +317,26 @@ impl<'a> Formatter<'a> { self.inline_comment_upper_bound = saved_bound; } + fn subexpr_preceded_by_not(&self, span_start: usize) -> bool { + if span_start == 0 || span_start > self.source.len() { + return false; + } + + let mut cursor = span_start; + while cursor > 0 && self.source[cursor - 1].is_ascii_whitespace() { + cursor -= 1; + } + let word_end = cursor; + + while cursor > 0 + && (self.source[cursor - 1].is_ascii_alphabetic() || self.source[cursor - 1] == b'-') + { + cursor -= 1; + } + + self.source[cursor..word_end].eq_ignore_ascii_case(b"not") + } + /// Format an expression that could be a block or a regular expression. pub(super) fn format_block_or_expr(&mut self, expr: &Expression) { match &expr.expr { diff --git a/tests/fixtures/config/margin_one_sets_single_blank_line_issue169.nuon b/tests/fixtures/config/margin_one_sets_single_blank_line_issue169.nuon new file mode 100644 index 0000000..0457da3 --- /dev/null +++ b/tests/fixtures/config/margin_one_sets_single_blank_line_issue169.nuon @@ -0,0 +1,5 @@ +{ + indent: 4 + line_length: 80 + margin: 1 +} diff --git a/tests/fixtures/config/margin_zero_allows_no_blank_line_issue169.nuon b/tests/fixtures/config/margin_zero_allows_no_blank_line_issue169.nuon new file mode 100644 index 0000000..ff35112 --- /dev/null +++ b/tests/fixtures/config/margin_zero_allows_no_blank_line_issue169.nuon @@ -0,0 +1,5 @@ +{ + indent: 4 + line_length: 80 + margin: 0 +} diff --git a/tests/fixtures/expected/alias_invocation_in_def_does_not_duplicate_expanded_rhs_issue171.nu b/tests/fixtures/expected/alias_invocation_in_def_does_not_duplicate_expanded_rhs_issue171.nu new file mode 100644 index 0000000..49779a3 --- /dev/null +++ b/tests/fixtures/expected/alias_invocation_in_def_does_not_duplicate_expanded_rhs_issue171.nu @@ -0,0 +1,4 @@ +alias ez = eza --git --git-repos --long --header --all --time-style=relative --group +def ezt [level: int = 2] { + ez --git --git-repos --long --header --time-style=relative --group --tree --level $level +} diff --git a/tests/fixtures/expected/margin_one_sets_single_blank_line_issue169.nu b/tests/fixtures/expected/margin_one_sets_single_blank_line_issue169.nu new file mode 100644 index 0000000..c476e45 --- /dev/null +++ b/tests/fixtures/expected/margin_one_sets_single_blank_line_issue169.nu @@ -0,0 +1,3 @@ +echo one + +echo two diff --git a/tests/fixtures/expected/margin_zero_allows_no_blank_line_issue169.nu b/tests/fixtures/expected/margin_zero_allows_no_blank_line_issue169.nu new file mode 100644 index 0000000..8769499 --- /dev/null +++ b/tests/fixtures/expected/margin_zero_allows_no_blank_line_issue169.nu @@ -0,0 +1,2 @@ +echo one +echo two diff --git a/tests/fixtures/expected/unary_not_condition_keeps_required_subexpression_parens_issue172.nu b/tests/fixtures/expected/unary_not_condition_keeps_required_subexpression_parens_issue172.nu new file mode 100644 index 0000000..5993dfe --- /dev/null +++ b/tests/fixtures/expected/unary_not_condition_keeps_required_subexpression_parens_issue172.nu @@ -0,0 +1,7 @@ +def has [cmd: string] { + which $cmd | is-not-empty +} + +if not (has systemctl) { + print ok +} diff --git a/tests/fixtures/input/alias_invocation_in_def_does_not_duplicate_expanded_rhs_issue171.nu b/tests/fixtures/input/alias_invocation_in_def_does_not_duplicate_expanded_rhs_issue171.nu new file mode 100644 index 0000000..49779a3 --- /dev/null +++ b/tests/fixtures/input/alias_invocation_in_def_does_not_duplicate_expanded_rhs_issue171.nu @@ -0,0 +1,4 @@ +alias ez = eza --git --git-repos --long --header --all --time-style=relative --group +def ezt [level: int = 2] { + ez --git --git-repos --long --header --time-style=relative --group --tree --level $level +} diff --git a/tests/fixtures/input/margin_one_sets_single_blank_line_issue169.nu b/tests/fixtures/input/margin_one_sets_single_blank_line_issue169.nu new file mode 100644 index 0000000..8769499 --- /dev/null +++ b/tests/fixtures/input/margin_one_sets_single_blank_line_issue169.nu @@ -0,0 +1,2 @@ +echo one +echo two diff --git a/tests/fixtures/input/margin_zero_allows_no_blank_line_issue169.nu b/tests/fixtures/input/margin_zero_allows_no_blank_line_issue169.nu new file mode 100644 index 0000000..8769499 --- /dev/null +++ b/tests/fixtures/input/margin_zero_allows_no_blank_line_issue169.nu @@ -0,0 +1,2 @@ +echo one +echo two diff --git a/tests/fixtures/input/unary_not_condition_keeps_required_subexpression_parens_issue172.nu b/tests/fixtures/input/unary_not_condition_keeps_required_subexpression_parens_issue172.nu new file mode 100644 index 0000000..5993dfe --- /dev/null +++ b/tests/fixtures/input/unary_not_condition_keeps_required_subexpression_parens_issue172.nu @@ -0,0 +1,7 @@ +def has [cmd: string] { + which $cmd | is-not-empty +} + +if not (has systemctl) { + print ok +} diff --git a/tests/ground_truth.rs b/tests/ground_truth.rs index 7bf0247..08ca7a7 100644 --- a/tests/ground_truth.rs +++ b/tests/ground_truth.rs @@ -35,6 +35,8 @@ pub fn get_test_binary() -> PathBuf { fn run_ground_truth_test(test_binary: &PathBuf, name: &str) { let input_path = PathBuf::from(format!("tests/fixtures/input/{}.nu", name)); let expected_path = PathBuf::from(format!("tests/fixtures/expected/{}.nu", name)); + let config_path = PathBuf::from(format!("tests/fixtures/config/{}.nuon", name)); + let config = config_path.exists().then_some(config_path.as_path()); // Ensure files exist assert!( @@ -52,7 +54,7 @@ fn run_ground_truth_test(test_binary: &PathBuf, name: &str) { let input = fs::read_to_string(&input_path).expect("Failed to read input file"); // Run formatter via stdin - let formatted = match format_via_stdin(test_binary, &input) { + let formatted = match format_via_stdin(test_binary, &input, config) { Ok(output) => output, Err(err) => panic!("Formatter failed for {}: {}", name, err), }; @@ -95,6 +97,8 @@ fn run_ground_truth_test(test_binary: &PathBuf, name: &str) { /// Test that formatting is idempotent (formatting twice gives same result) fn run_idempotency_test(test_binary: &PathBuf, name: &str) { let input_path = PathBuf::from(format!("tests/fixtures/input/{}.nu", name)); + let config_path = PathBuf::from(format!("tests/fixtures/config/{}.nuon", name)); + let config = config_path.exists().then_some(config_path.as_path()); if !input_path.exists() { return; // Skip if input doesn't exist @@ -103,14 +107,14 @@ fn run_idempotency_test(test_binary: &PathBuf, name: &str) { let input = fs::read_to_string(&input_path).expect("Failed to read input file"); // First format - let first_output = format_via_stdin(test_binary, &input); + let first_output = format_via_stdin(test_binary, &input, config); if first_output.is_err() { return; // Skip if formatting fails } let first = first_output.unwrap(); // Second format - let second_output = format_via_stdin(test_binary, &first); + let second_output = format_via_stdin(test_binary, &first, config); if second_output.is_err() { panic!("Second format failed for {}, but first succeeded", name); } @@ -126,9 +130,19 @@ fn run_idempotency_test(test_binary: &PathBuf, name: &str) { } } -fn format_via_stdin(test_binary: &PathBuf, input: &str) -> Result { - let output = Command::new(test_binary) - .arg("--stdin") +fn format_via_stdin( + test_binary: &PathBuf, + input: &str, + config: Option<&std::path::Path>, +) -> Result { + let mut command = Command::new(test_binary); + command.arg("--stdin"); + + if let Some(config_path) = config { + command.arg("--config").arg(config_path); + } + + let output = command .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) @@ -625,4 +639,24 @@ fixture_tests!( ground_truth_multiline_record_comments_preserved_issue168, idempotency_multiline_record_comments_preserved_issue168 ), + ( + "margin_one_sets_single_blank_line_issue169", + ground_truth_margin_one_sets_single_blank_line_issue169, + idempotency_margin_one_sets_single_blank_line_issue169 + ), + ( + "margin_zero_allows_no_blank_line_issue169", + ground_truth_margin_zero_allows_no_blank_line_issue169, + idempotency_margin_zero_allows_no_blank_line_issue169 + ), + ( + "alias_invocation_in_def_does_not_duplicate_expanded_rhs_issue171", + ground_truth_alias_invocation_in_def_does_not_duplicate_expanded_rhs_issue171, + idempotency_alias_invocation_in_def_does_not_duplicate_expanded_rhs_issue171 + ), + ( + "unary_not_condition_keeps_required_subexpression_parens_issue172", + ground_truth_unary_not_condition_keeps_required_subexpression_parens_issue172, + idempotency_unary_not_condition_keeps_required_subexpression_parens_issue172 + ), ); diff --git a/tests/main.rs b/tests/main.rs index fb6515f..69f9642 100644 --- a/tests/main.rs +++ b/tests/main.rs @@ -406,3 +406,91 @@ fn mixed_line_string_literal_and_pipeline_repair_are_safe_issue145() { "pipeline repair should still apply to executable code: {stdout}" ); } + +#[test] +fn margin_one_enforces_one_blank_line_and_margin_zero_is_supported_issue169() { + let dir = tempdir().unwrap(); + let config_margin_one = dir.path().join("nufmt-margin-one.nuon"); + let config_margin_zero = dir.path().join("nufmt-margin-zero.nuon"); + let margin_one_file = dir.path().join("margin_one_issue169.nu"); + let margin_zero_file = dir.path().join("margin_zero_issue169.nu"); + + fs::write( + &config_margin_one, + "{\n indent: 4\n line_length: 80\n margin: 1\n}\n", + ) + .unwrap(); + fs::write( + &config_margin_zero, + "{\n indent: 4\n line_length: 80\n margin: 0\n}\n", + ) + .unwrap(); + + fs::write(&margin_one_file, "echo one\necho two\n").unwrap(); + fs::write(&margin_zero_file, "echo one\necho two\n").unwrap(); + + let margin_one_output = Command::new(get_test_binary()) + .arg("--config") + .arg(config_margin_one.to_str().unwrap()) + .arg(margin_one_file.to_str().unwrap()) + .output() + .unwrap(); + + assert_eq!(margin_one_output.status.code(), Some(0)); + let margin_one_content = fs::read_to_string(&margin_one_file).unwrap(); + assert_eq!(margin_one_content, "echo one\n\necho two\n"); + + let margin_zero_output = Command::new(get_test_binary()) + .arg("--config") + .arg(config_margin_zero.to_str().unwrap()) + .arg(margin_zero_file.to_str().unwrap()) + .output() + .unwrap(); + + assert_eq!(margin_zero_output.status.code(), Some(0)); + let margin_zero_content = fs::read_to_string(&margin_zero_file).unwrap(); + assert_eq!(margin_zero_content, "echo one\necho two\n"); +} + +#[test] +fn alias_invocation_in_def_does_not_duplicate_expanded_rhs_issue171() { + let output = run_stdin( + "alias ez = eza --git --git-repos --long --header --all --time-style=relative --group\n\ +def ezt [level: int = 2] {\n\ + ez --git --git-repos --long --header --time-style=relative --group --tree --level $level\n\ +}\n", + ); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert_eq!(output.status.code(), Some(0)); + assert!( + stdout.contains( + "ez --git --git-repos --long --header --time-style=relative --group --tree --level $level" + ), + "expected explicit alias invocation args to be preserved: {stdout}" + ); + assert!( + !stdout.contains("--group --git --git-repos --long --header --time-style=relative --group"), + "unexpected duplicated alias expansion detected: {stdout}" + ); +} + +#[test] +fn unary_not_condition_keeps_required_subexpression_parens_issue172() { + let output = run_stdin( + "def has [cmd: string] {\n\ + which $cmd | is-not-empty\n\ +}\n\ +\n\ +if not (has systemctl) {\n\ + print ok\n\ +}\n", + ); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert_eq!(output.status.code(), Some(0)); + assert!( + stdout.contains("if not (has systemctl) {"), + "parenthesized condition should not be rewritten into invalid syntax: {stdout}" + ); +}