@@ -377,7 +377,7 @@ pub fn parse(input: &str) -> Result<SequentialList, ParseErrorFailureError> {
377377}
378378
379379fn 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
12691282fn 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).
13201335fn 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
13481396fn is_valid_env_var_byte ( b : u8 ) -> bool {
@@ -1474,6 +1522,113 @@ mod test {
14741522 assert_eq ! ( parse( "FOO=bar\n cmd" ) . 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\n echo bar" )
1575+ . unwrap( )
1576+ . items
1577+ . len( ) ,
1578+ 2 ,
1579+ ) ;
1580+ // a leading comment line is skipped
1581+ assert_eq ! ( parse( "# leading comment\n echo 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\n echo bar" ) . unwrap( ) . items. len( ) ,
1587+ 2
1588+ ) ;
1589+ // a comment following a boolean operator continuation
1590+ assert_eq ! (
1591+ parse( "echo foo && # comment\n echo 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\n grep bar" ) . unwrap( ) . items. len( ) ,
1600+ 1 ,
1601+ ) ;
1602+ // a comment line inside a subshell is skipped
1603+ assert_eq ! ( parse( "(echo a\n # comment\n echo 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,
0 commit comments