Skip to content

Commit 06c5416

Browse files
authored
feat: support shell-style # comments (#182)
1 parent 59b7f78 commit 06c5416

2 files changed

Lines changed: 213 additions & 8 deletions

File tree

src/parser.rs

Lines changed: 168 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,7 @@ pub fn parse(input: &str) -> Result<SequentialList, ParseErrorFailureError> {
377377
}
378378

379379
fn parse_sequential_list(input: &str) -> ParseResult<'_, SequentialList> {
380-
let (mut input, _) = skip_whitespace(input)?;
380+
let (mut input, _) = skip_whitespace_and_comments(input)?;
381381
let mut items = Vec::new();
382382
while !input.is_empty() {
383383
let (after_seq, sequence) = match parse_sequence(input) {
@@ -422,7 +422,7 @@ fn parse_list_item_separator(input: &str) -> ParseResult<'_, (bool, bool)> {
422422
}
423423
if let Ok((rest, _)) = or(tag("\r\n"), tag("\n"))(cursor) {
424424
ends_line = true;
425-
let (rest, _) = skip_whitespace(rest)?;
425+
let (rest, _) = skip_whitespace_and_comments(rest)?;
426426
cursor = rest;
427427
}
428428
if cursor == input {
@@ -604,7 +604,7 @@ fn parse_op_str<'a>(
604604
debug_assert!(operator == "&&" || operator == "||" || operator == "&");
605605
terminated(
606606
tag(operator),
607-
terminated(check_not(one_of("|&")), skip_whitespace),
607+
terminated(check_not(one_of("|&")), skip_whitespace_and_comments),
608608
)
609609
}
610610

@@ -616,7 +616,7 @@ fn parse_pipe_sequence_op(
616616
map(tag("|&"), |_| PipeSequenceOperator::StdoutStderr),
617617
map(ch('|'), |_| PipeSequenceOperator::Stdout),
618618
),
619-
terminated(check_not(one_of("|&")), skip_whitespace),
619+
terminated(check_not(one_of("|&")), skip_whitespace_and_comments),
620620
)(input)
621621
}
622622

@@ -628,9 +628,13 @@ fn parse_redirect(input: &str) -> ParseResult<'_, Redirect> {
628628
} else {
629629
(input, None)
630630
};
631+
let redirect_op_input = input;
631632
let (input, op) = or3(
632633
map(tag(">>"), |_| RedirectOp::Output(RedirectOpOutput::Append)),
633-
map(or(tag(">"), tag(">|")), |_| {
634+
// `>|` (clobber) must be tried before `>`, otherwise `>` matches the
635+
// prefix and the `|` is left to be misparsed as a pipe. We don't model
636+
// `noclobber`, so `>|` is treated the same as `>` (overwrite).
637+
map(or(tag(">|"), tag(">")), |_| {
634638
RedirectOp::Output(RedirectOpOutput::Overwrite)
635639
}),
636640
map(ch('<'), |_| RedirectOp::Input(RedirectOpInput::Redirect)),
@@ -640,6 +644,15 @@ fn parse_redirect(input: &str) -> ParseResult<'_, Redirect> {
640644
map(preceded(skip_inline_whitespace, parse_word), IoFile::Word),
641645
)(input)?;
642646

647+
// A redirect operator must be followed by a target. An empty word here
648+
// means the path was missing (e.g. `cmd >` at end of input, or a comment
649+
// beginning right after the operator as in `cmd > # comment`).
650+
if let IoFile::Word(word) = &io_file
651+
&& word.parts().is_empty()
652+
{
653+
return ParseError::fail(redirect_op_input, "Expected redirect path.");
654+
}
655+
643656
let maybe_fd = if let Some(fd) = maybe_fd {
644657
Some(RedirectFd::Fd(fd))
645658
} else if maybe_ampersand.is_some() {
@@ -1268,7 +1281,7 @@ fn parse_backticks_command_substitution(
12681281

12691282
fn parse_subshell(input: &str) -> ParseResult<'_, SequentialList> {
12701283
delimited(
1271-
terminated(ch('('), skip_whitespace),
1284+
terminated(ch('('), skip_whitespace_and_comments),
12721285
parse_sequential_list,
12731286
with_failure_input(
12741287
input,
@@ -1316,7 +1329,9 @@ fn assert_whitespace_or_end(input: &str) -> ParseResult<'_, ()> {
13161329
Ok((input, ()))
13171330
}
13181331

1319-
/// Skips space, tab, and `\<newline>` continuations. Leaves raw newlines.
1332+
/// Skips space, tab, and `\<newline>` continuations, plus a trailing
1333+
/// `#`-comment if one begins at the resulting word boundary. Leaves raw
1334+
/// newlines (a comment runs to, but does not consume, the next newline).
13201335
fn skip_inline_whitespace(input: &str) -> ParseResult<'_, ()> {
13211336
let bytes = input.as_bytes();
13221337
let mut i = 0;
@@ -1342,7 +1357,40 @@ fn skip_inline_whitespace(input: &str) -> ParseResult<'_, ()> {
13421357
_ => break,
13431358
}
13441359
}
1345-
Ok((&input[i..], ()))
1360+
Ok((skip_comment(&input[i..]), ()))
1361+
}
1362+
1363+
/// If `input` starts with a `#`, consume the comment up to (but not
1364+
/// including) the next newline. Otherwise return `input` unchanged.
1365+
///
1366+
/// Callers must invoke this only at a word boundary (after skipping
1367+
/// whitespace or following a metacharacter); a `#` in the middle of a
1368+
/// word is an ordinary character, not a comment.
1369+
fn skip_comment(input: &str) -> &str {
1370+
if input.starts_with('#') {
1371+
let end = input.find(['\n', '\r']).unwrap_or(input.len());
1372+
&input[end..]
1373+
} else {
1374+
input
1375+
}
1376+
}
1377+
1378+
/// Skips whitespace (including newlines) and any whole-line `#`-comments,
1379+
/// repeating until neither remains. Mirrors `skip_whitespace` but is
1380+
/// comment-aware, so it can be used both as `skip_whitespace_and_comments(input)?`
1381+
/// and as a combinator (e.g. `terminated(op, skip_whitespace_and_comments)`).
1382+
fn skip_whitespace_and_comments(input: &str) -> ParseResult<'_, ()> {
1383+
let mut current = input;
1384+
loop {
1385+
let (rest, _) = skip_whitespace(current)?;
1386+
let rest = skip_comment(rest);
1387+
// `rest` is always a suffix of `current`, so equal lengths means
1388+
// nothing was consumed — compare lengths to avoid a byte-wise compare.
1389+
if rest.len() == current.len() {
1390+
return Ok((current, ()));
1391+
}
1392+
current = rest;
1393+
}
13461394
}
13471395

13481396
fn is_valid_env_var_byte(b: u8) -> bool {
@@ -1474,6 +1522,113 @@ mod test {
14741522
assert_eq!(parse("FOO=bar\ncmd").unwrap().items.len(), 2);
14751523
}
14761524

1525+
#[test]
1526+
fn comments() {
1527+
fn single_command_args(input: &str) -> Vec<Word> {
1528+
let list = parse(input).unwrap();
1529+
assert_eq!(list.items.len(), 1, "input: {input:?}");
1530+
let Sequence::Pipeline(pipeline) = &list.items[0].sequence else {
1531+
panic!("expected pipeline for input: {input:?}");
1532+
};
1533+
let PipelineInner::Command(cmd) = &pipeline.inner else {
1534+
panic!("expected command for input: {input:?}");
1535+
};
1536+
let CommandInner::Simple(simple) = &cmd.inner else {
1537+
panic!("expected simple command for input: {input:?}");
1538+
};
1539+
simple.args.clone()
1540+
}
1541+
1542+
// trailing comment after a command (the case from denoland/deno#27644)
1543+
assert_eq!(
1544+
single_command_args("echo foo # this is a comment"),
1545+
vec![Word::new_word("echo"), Word::new_word("foo")],
1546+
);
1547+
// no space before `#` is still a comment at a word boundary
1548+
assert_eq!(
1549+
single_command_args("echo foo #comment"),
1550+
vec![Word::new_word("echo"), Word::new_word("foo")],
1551+
);
1552+
// `#` in the middle of a word is a literal character, not a comment
1553+
assert_eq!(
1554+
single_command_args("echo foo#bar"),
1555+
vec![Word::new_word("echo"), Word::new_word("foo#bar")],
1556+
);
1557+
// `#` attached to the start of a word (no preceding space) is literal
1558+
assert_eq!(
1559+
single_command_args("echo #foo"),
1560+
vec![Word::new_word("echo")],
1561+
);
1562+
// `#` inside quotes is literal
1563+
assert_eq!(
1564+
single_command_args("echo '# not a comment'"),
1565+
vec![Word::new_word("echo"), Word::new_string("# not a comment"),],
1566+
);
1567+
assert_eq!(
1568+
single_command_args("echo \"# not a comment\""),
1569+
vec![Word::new_word("echo"), Word::new_string("# not a comment"),],
1570+
);
1571+
1572+
// a comment line between commands is skipped
1573+
assert_eq!(
1574+
parse("echo foo\n# a comment\necho bar")
1575+
.unwrap()
1576+
.items
1577+
.len(),
1578+
2,
1579+
);
1580+
// a leading comment line is skipped
1581+
assert_eq!(parse("# leading comment\necho foo").unwrap().items.len(), 1);
1582+
// a trailing comment line is skipped
1583+
assert_eq!(parse("echo foo\n# trailing").unwrap().items.len(), 1);
1584+
// a comment after `;`
1585+
assert_eq!(
1586+
parse("echo foo ;# comment\necho bar").unwrap().items.len(),
1587+
2
1588+
);
1589+
// a comment following a boolean operator continuation
1590+
assert_eq!(
1591+
parse("echo foo && # comment\necho bar")
1592+
.unwrap()
1593+
.items
1594+
.len(),
1595+
1,
1596+
);
1597+
// a comment following a pipe continuation
1598+
assert_eq!(
1599+
parse("echo foo | # comment\ngrep bar").unwrap().items.len(),
1600+
1,
1601+
);
1602+
// a comment line inside a subshell is skipped
1603+
assert_eq!(parse("(echo a\n# comment\necho b)").unwrap().items.len(), 1);
1604+
// a comment-only input is an empty command
1605+
assert_eq!(
1606+
parse("# just a comment").err().unwrap().to_string(),
1607+
"Empty command.",
1608+
);
1609+
// a comment right after a redirect operator leaves no path: error
1610+
assert_eq!(
1611+
parse("echo foo > # comment").err().unwrap().to_string(),
1612+
concat!("Expected redirect path.\n", " > # comment\n", " ~"),
1613+
);
1614+
}
1615+
1616+
#[test]
1617+
fn redirect_missing_path_is_error() {
1618+
// a redirect operator with no following path is a parse error,
1619+
// not a redirect to an empty target
1620+
assert_eq!(
1621+
parse("echo foo >").err().unwrap().to_string(),
1622+
concat!("Expected redirect path.\n", " >\n", " ~"),
1623+
);
1624+
assert_eq!(
1625+
parse("cat <").err().unwrap().to_string(),
1626+
concat!("Expected redirect path.\n", " <\n", " ~"),
1627+
);
1628+
// still parses normally with a path
1629+
assert_eq!(parse("echo foo > out").unwrap().items.len(), 1);
1630+
}
1631+
14771632
#[test]
14781633
fn item_separator_flags() {
14791634
// `;` → sync, same line
@@ -2388,6 +2543,11 @@ mod test {
23882543
}),
23892544
);
23902545

2546+
// clobber (`>|`) is treated as overwrite; the `>|` must win over `>`
2547+
// so the `|` isn't left behind and misparsed as a pipe
2548+
run_test(parse_command, r#"echo 1 >| test.txt"#, expected.clone());
2549+
run_test(parse_command, r#"echo 1 >|test.txt"#, expected.clone());
2550+
23912551
// output redirect to fd
23922552
run_test(
23932553
parse_command,

tests/integration_test.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,51 @@ mod test_builder;
1414

1515
const FOLDER_SEPERATOR: char = if cfg!(windows) { '\\' } else { '/' };
1616

17+
#[tokio::test]
18+
async fn comments() {
19+
// trailing comment after a command (denoland/deno#27644)
20+
TestBuilder::new()
21+
.command("echo foo # this is a comment")
22+
.assert_stdout("foo\n")
23+
.run()
24+
.await;
25+
26+
// `#` with no preceding space is still a comment at a word boundary
27+
TestBuilder::new()
28+
.command("echo foo #comment")
29+
.assert_stdout("foo\n")
30+
.run()
31+
.await;
32+
33+
// `#` in the middle of a word is a literal character, not a comment
34+
TestBuilder::new()
35+
.command("echo foo#bar")
36+
.assert_stdout("foo#bar\n")
37+
.run()
38+
.await;
39+
40+
// `#` inside quotes is literal
41+
TestBuilder::new()
42+
.command("echo '# not a comment'")
43+
.assert_stdout("# not a comment\n")
44+
.run()
45+
.await;
46+
47+
// comment lines interspersed with commands
48+
TestBuilder::new()
49+
.command("# leading\necho foo\n# middle\necho bar\n# trailing")
50+
.assert_stdout("foo\nbar\n")
51+
.run()
52+
.await;
53+
54+
// comment after a `&&` continuation
55+
TestBuilder::new()
56+
.command("echo foo && # comment\necho bar")
57+
.assert_stdout("foo\nbar\n")
58+
.run()
59+
.await;
60+
}
61+
1762
#[tokio::test]
1863
async fn commands() {
1964
TestBuilder::new()

0 commit comments

Comments
 (0)