fish-shell/src/tests/parse_util.rs
Johannes Altmanninger b00899179f Don't indent multi-line quoted strings; do indent inside ()
On a command with multiline quoted string like

    begin
        echo "line1
    line2"
    end

we actually indent line2 which seeems misleading because the indentation
changes the behavior when typed into a script.

This has become more prominent since commits
- a37629f86 (fish_clipboard_copy: indent multiline commands, 2024-04-13)
- 611a0572b (builtins type/functions: indent interactively-defined functions, 2024-04-12)
- 222673f33 (edit_command_buffer: send indented commandline to editor, 2024-04-12)

which add indentation to an exported commandline.

Never indent quoted strings, to make sure the rendering matches the semantics.
Note that we do need to indent the opening quote which is fine because
it's on the same line.

While at it, indent command substitutions recursively.  That feature should
also be added to fish_indent's formatting mode (which is the default).
Fortunately the formatting mode already works fine with quoted strings;
it does not indent them. Not sure how that's done and whether indentation
can use the same logic.
2024-04-30 14:12:48 +02:00

440 lines
13 KiB
Rust

use pcre2::utf32::Regex;
use crate::common::EscapeFlags;
use crate::parse_constants::{
ERROR_BAD_VAR_CHAR1, ERROR_BRACKETED_VARIABLE1, ERROR_BRACKETED_VARIABLE_QUOTED1,
ERROR_NOT_ARGV_AT, ERROR_NOT_ARGV_COUNT, ERROR_NOT_ARGV_STAR, ERROR_NOT_PID, ERROR_NOT_STATUS,
ERROR_NO_VAR_NAME,
};
use crate::parse_util::{
parse_util_cmdsubst_extent, parse_util_compute_indents, parse_util_detect_errors,
parse_util_escape_string_with_quote, parse_util_process_extent, parse_util_slice_length,
BOOL_AFTER_BACKGROUND_ERROR_MSG,
};
use crate::tests::prelude::*;
use crate::wchar::prelude::*;
#[test]
#[serial]
fn test_error_messages() {
let _cleanup = test_init();
// Given a format string, returns a list of non-empty strings separated by format specifiers. The
// format specifiers themselves are omitted.
fn separate_by_format_specifiers(format: &wstr) -> Vec<&wstr> {
let format_specifier_regex = Regex::new(L!(r"%l?[cds]").as_char_slice()).unwrap();
let mut result = vec![];
let mut offset = 0;
for mtch in format_specifier_regex.find_iter(format.as_char_slice()) {
let mtch = mtch.unwrap();
let component = &format[offset..mtch.start()];
result.push(component);
offset = mtch.end();
}
result.push(&format[offset..]);
// Avoid mismatch from localized quotes.
for component in &mut result {
*component = component.trim_matches('\'');
}
result
}
// Given a format string 'format', return true if the string may have been produced by that format
// string. We do this by splitting the format string around the format specifiers, and then ensuring
// that each of the remaining chunks is found (in order) in the string.
fn string_matches_format(s: &wstr, format: &wstr) -> bool {
let components = separate_by_format_specifiers(format);
assert!(!components.is_empty());
let mut idx = 0;
for component in components {
let Some(relpos) = s[idx..].find(component) else {
return false;
};
idx += relpos + component.len();
assert!(idx <= s.len());
}
true
}
macro_rules! validate {
($src:expr, $error_text_format:expr) => {
let mut errors = vec![];
let res = parse_util_detect_errors(L!($src), Some(&mut errors), false);
let fmt = wgettext!($error_text_format);
assert!(res.is_err());
assert!(
string_matches_format(&errors[0].text, fmt),
"command '{}' is expected to match error pattern '{}' but is '{}'",
$src,
$error_text_format,
&errors[0].text
);
};
}
validate!("echo $^", ERROR_BAD_VAR_CHAR1);
validate!("echo foo${a}bar", ERROR_BRACKETED_VARIABLE1);
validate!("echo foo\"${a}\"bar", ERROR_BRACKETED_VARIABLE_QUOTED1);
validate!("echo foo\"${\"bar", ERROR_BAD_VAR_CHAR1);
validate!("echo $?", ERROR_NOT_STATUS);
validate!("echo $$", ERROR_NOT_PID);
validate!("echo $#", ERROR_NOT_ARGV_COUNT);
validate!("echo $@", ERROR_NOT_ARGV_AT);
validate!("echo $*", ERROR_NOT_ARGV_STAR);
validate!("echo $", ERROR_NO_VAR_NAME);
validate!("echo foo\"$\"bar", ERROR_NO_VAR_NAME);
validate!("echo \"foo\"$\"bar\"", ERROR_NO_VAR_NAME);
validate!("echo foo $ bar", ERROR_NO_VAR_NAME);
validate!("echo 1 & && echo 2", BOOL_AFTER_BACKGROUND_ERROR_MSG);
validate!(
"echo 1 && echo 2 & && echo 3",
BOOL_AFTER_BACKGROUND_ERROR_MSG
);
}
#[test]
fn test_parse_util_process_extent() {
macro_rules! validate {
($commandline:literal, $cursor:expr, $expected_range:expr) => {
assert_eq!(
parse_util_process_extent(L!($commandline), $cursor, None),
$expected_range
);
};
}
validate!("for file in (path base\necho", 22, 13..22);
}
#[test]
#[serial]
fn test_parse_util_cmdsubst_extent() {
let _cleanup = test_init();
const a: &wstr = L!("echo (echo (echo hi");
assert_eq!(parse_util_cmdsubst_extent(a, 0), 0..a.len());
assert_eq!(parse_util_cmdsubst_extent(a, 1), 0..a.len());
assert_eq!(parse_util_cmdsubst_extent(a, 2), 0..a.len());
assert_eq!(parse_util_cmdsubst_extent(a, 3), 0..a.len());
assert_eq!(
parse_util_cmdsubst_extent(a, 8),
"echo (".chars().count()..a.len()
);
assert_eq!(
parse_util_cmdsubst_extent(a, 17),
"echo (echo (".chars().count()..a.len()
);
}
#[test]
#[serial]
fn test_parse_util_slice_length() {
let _cleanup = test_init();
assert_eq!(parse_util_slice_length(L!("[2]")), Some(3));
assert_eq!(parse_util_slice_length(L!("[12]")), Some(4));
assert_eq!(parse_util_slice_length(L!("[\"foo\"]")), Some(7));
assert_eq!(parse_util_slice_length(L!("[\"foo\"")), None);
}
#[test]
#[serial]
fn test_escape_quotes() {
let _cleanup = test_init();
macro_rules! validate {
($cmd:expr, $quote:expr, $no_tilde:expr, $expected:expr) => {
assert_eq!(
parse_util_escape_string_with_quote(
L!($cmd),
$quote,
if $no_tilde {
EscapeFlags::NO_TILDE
} else {
EscapeFlags::empty()
}
),
L!($expected)
);
};
}
macro_rules! validate_no_quoted {
($cmd:expr, $quote:expr, $no_tilde:expr, $expected:expr) => {
assert_eq!(
parse_util_escape_string_with_quote(
L!($cmd),
$quote,
EscapeFlags::NO_QUOTED
| if $no_tilde {
EscapeFlags::NO_TILDE
} else {
EscapeFlags::empty()
}
),
L!($expected)
);
};
}
validate!("abc~def", None, false, "'abc~def'");
validate!("abc~def", None, true, "abc~def");
validate!("~abc", None, false, "'~abc'");
validate!("~abc", None, true, "~abc");
// These are "raw string literals"
validate_no_quoted!("abc", None, false, "abc");
validate_no_quoted!("abc~def", None, false, "abc\\~def");
validate_no_quoted!("abc~def", None, true, "abc~def");
validate_no_quoted!("abc\\~def", None, false, "abc\\\\\\~def");
validate_no_quoted!("abc\\~def", None, true, "abc\\\\~def");
validate_no_quoted!("~abc", None, false, "\\~abc");
validate_no_quoted!("~abc", None, true, "~abc");
validate_no_quoted!("~abc|def", None, false, "\\~abc\\|def");
validate_no_quoted!("|abc~def", None, false, "\\|abc\\~def");
validate_no_quoted!("|abc~def", None, true, "\\|abc~def");
validate_no_quoted!("foo\nbar", None, false, "foo\\nbar");
// Note tildes are not expanded inside quotes, so no_tilde is ignored with a quote.
validate_no_quoted!("abc", Some('\''), false, "abc");
validate_no_quoted!("abc\\def", Some('\''), false, "abc\\\\def");
validate_no_quoted!("abc'def", Some('\''), false, "abc\\'def");
validate_no_quoted!("~abc'def", Some('\''), false, "~abc\\'def");
validate_no_quoted!("~abc'def", Some('\''), true, "~abc\\'def");
validate_no_quoted!("foo\nba'r", Some('\''), false, "foo'\\n'ba\\'r");
validate_no_quoted!("foo\\\\bar", Some('\''), false, "foo\\\\\\\\bar");
validate_no_quoted!("abc", Some('"'), false, "abc");
validate_no_quoted!("abc\\def", Some('"'), false, "abc\\\\def");
validate_no_quoted!("~abc'def", Some('"'), false, "~abc'def");
validate_no_quoted!("~abc'def", Some('"'), true, "~abc'def");
validate_no_quoted!("foo\nba'r", Some('"'), false, "foo\"\\n\"ba'r");
validate_no_quoted!("foo\\\\bar", Some('"'), false, "foo\\\\\\\\bar");
}
#[test]
#[serial]
fn test_indents() {
let _cleanup = test_init();
// A struct which is either text or a new indent.
struct Segment {
// The indent to set
indent: i32,
text: &'static str,
}
fn do_validate(segments: &[Segment]) {
// Compute the indents.
let mut expected_indents = vec![];
let mut text = WString::new();
for segment in segments {
text.push_str(segment.text);
for _ in segment.text.chars() {
expected_indents.push(segment.indent);
}
}
let indents = parse_util_compute_indents(&text);
assert_eq!(indents, expected_indents);
}
macro_rules! validate {
( $( $(,)? $indent:literal, $text:literal )* $(,)? ) => {
let segments = vec![
$(
Segment{ indent: $indent, text: $text },
)*
];
do_validate(&segments);
};
}
#[rustfmt::skip]
#[allow(clippy::redundant_closure_call)]
(|| {
validate!(
0, "if", 1, " foo",
0, "\nend"
);
validate!(
0, "if", 1, " foo",
1, "\nfoo",
0, "\nend"
);
validate!(
0, "if", 1, " foo",
1, "\nif", 2, " bar",
1, "\nend",
0, "\nend"
);
validate!(
0, "if", 1, " foo",
1, "\nif", 2, " bar",
2, "\n",
1, "\nend\n"
);
validate!(
0, "if", 1, " foo",
1, "\nif", 2, " bar",
2, "\n"
);
validate!(
0, "begin",
1, "\nfoo",
1, "\n"
);
validate!(
0, "begin",
1, "\n;",
0, "end",
0, "\nfoo", 0, "\n"
);
validate!(
0, "begin",
1, "\n;",
0, "end",
0, "\nfoo", 0, "\n"
);
validate!(
0, "if", 1, " foo",
1, "\nif", 2, " bar",
2, "\nbaz",
1, "\nend", 1, "\n"
);
validate!(
0, "switch foo",
1, "\n"
);
validate!(
0, "switch foo",
1, "\ncase bar",
1, "\ncase baz",
2, "\nquux",
2, "\nquux"
);
validate!(
0,
"switch foo",
1,
"\ncas" // parse error indentation handling
);
validate!(
0, "while",
1, " false",
1, "\n# comment", // comment indentation handling
1, "\ncommand",
1, "\n# comment 2"
);
validate!(
0, "begin",
1, "\n", // "begin" is special because this newline belongs to the block header
1, "\n"
);
// Continuation lines.
validate!(
0, "echo 'continuation line' \\",
1, "\ncont",
0, "\n"
);
validate!(
0, "echo 'empty continuation line' \\",
1, "\n"
);
validate!(
0, "begin # continuation line in block",
1, "\necho \\",
2, "\ncont"
);
validate!(
0, "begin # empty continuation line in block",
1, "\necho \\",
2, "\n",
0, "\nend"
);
validate!(
0, "echo 'multiple continuation lines' \\",
1, "\nline1 \\",
1, "\n# comment",
1, "\n# more comment",
1, "\nline2 \\",
1, "\n"
);
validate!(
0, "echo # inline comment ending in \\",
0, "\nline"
);
validate!(
0, "# line comment ending in \\",
0, "\nline"
);
validate!(
0, "echo 'multiple empty continuation lines' \\",
1, "\n\\",
1, "\n",
0, "\n"
);
validate!(
0, "echo 'multiple statements with continuation lines' \\",
1, "\nline 1",
0, "\necho \\",
1, "\n"
);
// This is an edge case, probably okay to change the behavior here.
validate!(
0, "begin",
1, " \\",
2, "\necho 'continuation line in block header' \\",
2, "\n",
1, "\n",
0, "\nend"
);
validate!(
0, "if", 1, " true",
1, "\n begin",
2, "\n echo",
1, "\n end",
0, "\nend",
);
// Quotes and command substitutions.
validate!(
0, "if", 1, " foo \"",
0, "\nquoted",
);
validate!(
0, "if", 1, " foo \"",
0, "\n",
);
validate!(
0, "echo (",
1, "\n", // )
);
validate!(
0, "echo \"$(",
1, "\n" // )
);
validate!(
0, "echo (", // )
1, "\necho \"",
0, "\n"
);
validate!(
0, "echo (", // )
1, "\necho (", // )
2, "\necho"
);
validate!(
0, "if", 1, " true",
1, "\n echo \"line1",
0, "\nline2 ", 1, "$(",
2, "\n echo line3",
0, "\n) line4",
0, "\nline5\"",
);
})();
}