yash-syntax 0.23.0

POSIX-compatible shell script syntax parser
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
// This file is part of yash, an extended POSIX shell.
// Copyright (C) 2020 WATANABE Yuki
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program.  If not, see <https://www.gnu.org/licenses/>.

//! Definition of errors that happen in the parser

use super::lex::Operator;
use crate::source::Location;
use crate::source::pretty::{
    Footnote, FootnoteType, Report, ReportType, Snippet, Span, SpanRole, add_span,
};
use crate::syntax::AndOr;
use crate::syntax::RedirOp;
use std::borrow::Cow;
use std::rc::Rc;
use thiserror::Error;

/// Types of syntax errors
#[derive(Clone, Debug, Eq, Error, PartialEq)]
#[error("{}", self.message())]
#[non_exhaustive]
pub enum SyntaxError {
    /// A backslash is at the end of the input.
    IncompleteEscape,
    /// A backslash is not followed by a character that makes a valid escape.
    InvalidEscape,
    /// A `(` lacks a closing `)`.
    UnclosedParen { opening_location: Location },
    /// A single quotation lacks a closing `'`.
    UnclosedSingleQuote { opening_location: Location },
    /// A double quotation lacks a closing `"`.
    UnclosedDoubleQuote { opening_location: Location },
    /// A `$'` lacks a closing `'`.
    UnclosedDollarSingleQuote { opening_location: Location },
    /// A parameter expansion lacks a closing `}`.
    UnclosedParam { opening_location: Location },
    /// A parameter expansion lacks a name.
    EmptyParam,
    /// A parameter expansion has an invalid name.
    InvalidParam,
    /// A modifier does not have a valid form in a parameter expansion.
    InvalidModifier,
    /// A braced parameter expansion has both a prefix and suffix modifier.
    MultipleModifier,
    /// A command substitution started with `$(` but lacks a closing `)`.
    UnclosedCommandSubstitution { opening_location: Location },
    /// A command substitution started with `` ` `` but lacks a closing `` ` ``.
    UnclosedBackquote { opening_location: Location },
    /// An arithmetic expansion lacks a closing `))`.
    UnclosedArith { opening_location: Location },
    /// A command begins with an inappropriate keyword or operator token.
    InvalidCommandToken,
    /// A separator is missing between commands.
    MissingSeparator,
    /// The file descriptor specified for a redirection cannot be used.
    FdOutOfRange,
    /// An I/O location prefix attached to a redirection has an unsupported format.
    InvalidIoLocation,
    /// A redirection operator is missing its operand.
    MissingRedirOperand,
    /// A here-document operator is missing its delimiter token.
    MissingHereDocDelimiter,
    /// A here-document operator is missing its corresponding content.
    MissingHereDocContent,
    /// A here-document content is missing its delimiter.
    UnclosedHereDocContent { redir_op_location: Location },
    /// An array assignment started with `=(` but lacks a closing `)`.
    UnclosedArrayValue { opening_location: Location },
    /// A `}` appears without a matching `{`.
    UnopenedGrouping,
    /// A grouping is not closed.
    UnclosedGrouping { opening_location: Location },
    /// A grouping contains no commands.
    EmptyGrouping,
    /// A `)` appears without a matching `(`.
    UnopenedSubshell,
    /// A subshell is not closed.
    UnclosedSubshell { opening_location: Location },
    /// A subshell contains no commands.
    EmptySubshell,
    /// A `do` appears outside a loop.
    UnopenedLoop,
    /// A `done` appears outside a loop.
    UnopenedDoClause,
    /// A do clause is not closed.
    UnclosedDoClause { opening_location: Location },
    /// A do clause contains no commands.
    EmptyDoClause,
    /// The variable name is missing in a for loop.
    MissingForName,
    /// The variable name is not a valid word in a for loop.
    InvalidForName,
    /// A value is not a valid word in a for loop.
    InvalidForValue,
    /// A for loop is missing a do clause.
    MissingForBody { opening_location: Location },
    /// A while loop is missing a do clause.
    UnclosedWhileClause { opening_location: Location },
    /// A while loop's condition is empty.
    EmptyWhileCondition,
    /// An until loop is missing a do clause.
    UnclosedUntilClause { opening_location: Location },
    /// An until loop's condition is empty.
    EmptyUntilCondition,
    /// An if command is missing the then clause.
    IfMissingThen { if_location: Location },
    /// An if command's condition is empty.
    EmptyIfCondition,
    /// An if command's body is empty.
    EmptyIfBody,
    /// An elif clause is missing the then clause.
    ElifMissingThen { elif_location: Location },
    /// An elif clause's condition is empty.
    EmptyElifCondition,
    /// An elif clause's body is empty.
    EmptyElifBody,
    /// An else clause is empty.
    EmptyElse,
    /// An `elif`, `else`, `then`, or `fi` appears outside an if command.
    UnopenedIf,
    /// An if command is not closed.
    UnclosedIf { opening_location: Location },
    /// The case command is missing its subject.
    MissingCaseSubject,
    /// The subject of the case command is not a valid word.
    InvalidCaseSubject,
    /// The case command is missing `in` after the subject.
    MissingIn { opening_location: Location },
    /// The `)` is missing in a case item.
    UnclosedPatternList,
    /// The pattern is missing in a case item.
    MissingPattern,
    /// The pattern is not a valid word token.
    InvalidPattern,
    /// The first pattern of a case item is `esac`.
    #[deprecated(since = "0.12.1", note = "this error no longer occurs")]
    EsacAsPattern,
    /// An `esac` or `;;` appears outside a case command.
    UnopenedCase,
    /// A case command is not closed.
    UnclosedCase { opening_location: Location },
    /// The `(` is not followed by `)` in a function definition.
    UnmatchedParenthesis,
    /// The function body is missing in a function definition command.
    MissingFunctionBody,
    /// A function body is not a compound command.
    InvalidFunctionBody,
    /// The keyword `in` is used as a command name.
    InAsCommandName,
    /// The keyword `]]` is used as a command name.
    CloseBracketBracketAsCommandName,
    /// A pipeline is missing after a `&&` or `||` token.
    MissingPipeline(AndOr),
    /// Two successive `!` tokens.
    DoubleNegation,
    /// A `|` token is followed by a `!`.
    BangAfterBar,
    /// A command is missing after a `!` token.
    MissingCommandAfterBang,
    /// A command is missing after a `|` token.
    MissingCommandAfterBar,
    /// There is a redundant token.
    RedundantToken,
    /// A control escape (`\c...`) is incomplete in a dollar-single-quoted string.
    IncompleteControlEscape,
    /// A control-backslash escape (`\c\\`) is incomplete in a dollar-single-quoted string.
    IncompleteControlBackslashEscape,
    /// A control escape (`\c...`) does not have a valid control character.
    InvalidControlEscape,
    /// An octal escape is out of range (greater than `\377`) in a dollar-single-quoted string.
    OctalEscapeOutOfRange,
    /// An hexadecimal escape (`\x...`) is incomplete in a dollar-single-quoted string.
    IncompleteHexEscape,
    /// A Unicode escape (`\u...`) is incomplete in a dollar-single-quoted string.
    IncompleteShortUnicodeEscape,
    /// A Unicode escape (`\U...`) is incomplete in a dollar-single-quoted string.
    IncompleteLongUnicodeEscape,
    /// A Unicode escape (`\u...` or `\U...`) is out of range in a dollar-single-quoted string.
    UnicodeEscapeOutOfRange,
    /// The unsupported version of function definition syntax is used.
    UnsupportedFunctionDefinitionSyntax,
    /// A `[[ ... ]]` command is used.
    UnsupportedDoubleBracketCommand,
    /// A `namespace` command is used.
    UnsupportedNamespaceCommand,
    /// A `select` command is used.
    UnsupportedSelectCommand,
    /// A process redirection (`>(...)` or `<(...)`) is used.
    UnsupportedProcessRedirection,
    /// A `((...))` arithmetic command is used at the beginning of a command
    /// while the `portable` option is on.
    ///
    /// yash-rs parses `((` as nested subshells, but other shells parse it as an
    /// arithmetic command, which yash-rs does not support. The `portable` option
    /// rejects this ambiguous form; insert a space (`( (`) for nested subshells.
    UnsupportedArithmeticCommand,
    /// A `!(...)` extended glob is used at the beginning of a command while the
    /// `portable` option is on.
    ///
    /// yash-rs parses `!(` as the `!` reserved word followed by a subshell, but
    /// other shells parse it as an extended glob, which yash-rs does not support.
    /// The `portable` option rejects this ambiguous form; insert a space (`! (`).
    UnsupportedExtendedGlob,
    /// A `;;&` or `;|` case terminator is used while the `portable` option is on.
    ///
    /// The operator is the offending terminator (`SemicolonSemicolonAnd` or
    /// `SemicolonBar`).
    NonPortableCaseTerminator(Operator),
    /// A non-portable redirection operator (`>>|` or `<<<`) is used while the
    /// `portable` option is on.
    ///
    /// The operator is the offending redirection operator (`Pipe` or `String`).
    NonPortableRedirOperator(RedirOp),
    /// An `IO_NUMBER` or `IO_LOCATION` token appears as a redirection operand
    /// while the `portable` option is on.
    ///
    /// This happens when a token that should be a redirection operand is
    /// immediately followed by a redirection operator without a separating
    /// space, so yash-rs lexes it as an `IO_NUMBER` or `IO_LOCATION` token (as
    /// in the `1` in `< 1>file`). POSIX does not recognize such a token as a
    /// redirection operand, so this form is not portable.
    IoTokenAsRedirOperand,
    /// A reserved word follows a subshell or a redirection without a separator
    /// while the `portable` option is on (as in `{ ( : ) }` or
    /// `for i in 1; do ( : ) done`).
    ///
    /// POSIX recognizes a reserved word only when it is the first word of a
    /// command or follows another reserved word. A subshell ends with the `)`
    /// operator and a redirection ends with a word, so a clause-delimiting
    /// reserved word (such as `}`, `done`, or `fi`) that immediately follows one
    /// is not portably recognized.
    MissingSeparatorBeforeReservedWord,
    /// A command name ends with a `:` while the `portable` option is on.
    ///
    /// POSIX reserves words whose final character is a `:` for possible future
    /// use, so such a word produces unspecified results when used where a
    /// reserved word would be recognized (such as a command name). The lone
    /// `:` (the colon built-in) is not affected.
    ColonSuffixedCommandName,
    /// A non-portable escape sequence is used in a dollar-single-quoted string
    /// while the `portable` option is on.
    ///
    /// POSIX specifies a limited set of escape sequences for `$'...'`. This is
    /// raised for yash extensions such as `\E`, `\?`, `\u`, `\U`, and `\c@`.
    NonPortableEscape,
    /// A `\x` escape in a dollar-single-quoted string is followed by more than
    /// two hexadecimal digits while the `portable` option is on.
    ///
    /// POSIX leaves the result unspecified if more than two hexadecimal digits
    /// follow `\x`, so such an escape is not portable.
    TooLongHexEscape,
    /// A `for` loop variable name is not a portable name while the `portable`
    /// option is on.
    ///
    /// POSIX requires the name to be an unquoted `NAME` token consisting
    /// solely of underscores, digits, and alphabetics from the portable
    /// character set, not starting with a digit. This is raised when the name
    /// is quoted, contains an expansion, or otherwise does not meet this
    /// requirement.
    NonPortableForName,
    /// A function name is not a portable name while the `portable` option is
    /// on.
    ///
    /// POSIX requires the name to be an unquoted `NAME` token consisting
    /// solely of underscores, digits, and alphabetics from the portable
    /// character set, not starting with a digit. This is raised when the name
    /// is quoted, contains an expansion, or otherwise does not meet this
    /// requirement.
    NonPortableFunctionName,
    /// An assignment name is not a portable name while the `portable` option
    /// is on.
    ///
    /// A portable name consists solely of underscores, digits, and
    /// alphabetics from the portable character set, not starting with a
    /// digit. This is raised when the assignment name does not meet this
    /// form, since other POSIX-conforming shells may not support it.
    NonPortableAssignmentName,
    /// A function name is the same as a special built-in utility name while
    /// the `portable` option is on.
    ///
    /// POSIX does not allow a function to have the same name as a special
    /// built-in utility. See
    /// [`POSIX_SPECIAL_BUILTIN_NAMES`](yash_env::builtin::POSIX_SPECIAL_BUILTIN_NAMES)
    /// for the list of names this applies to.
    SpecialBuiltinFunctionName,
    /// A parameter expansion combines a special parameter with a modifier whose
    /// result POSIX leaves unspecified, while the `portable` option is on.
    ///
    /// POSIX leaves the result unspecified for a length or switch modifier
    /// applied to the special parameter `*` or `@` (as in `${#*}` or
    /// `${@:-x}`), and for a trim modifier applied to the special parameter
    /// `#`, `*`, or `@` (as in `${#%x}` or `${*#x}`).
    NonPortableParamModifier,
}

impl SyntaxError {
    /// Returns an error message describing the error.
    #[must_use]
    pub fn message(&self) -> &'static str {
        use SyntaxError::*;
        match self {
            IncompleteEscape => "the backslash is escaping nothing",
            InvalidEscape => "the backslash escape is invalid",
            UnclosedParen { .. } => "the parenthesis is not closed",
            UnclosedSingleQuote { .. } => "the single quote is not closed",
            UnclosedDoubleQuote { .. } => "the double quote is not closed",
            UnclosedDollarSingleQuote { .. } => "the dollar single quote is not closed",
            UnclosedParam { .. } => "the parameter expansion is not closed",
            EmptyParam => "the parameter name is missing",
            InvalidParam => "the parameter name is invalid",
            InvalidModifier => "the parameter expansion contains a malformed modifier",
            MultipleModifier => "a suffix modifier cannot be used together with a prefix modifier",
            UnclosedCommandSubstitution { .. } => "the command substitution is not closed",
            UnclosedBackquote { .. } => "the backquote is not closed",
            UnclosedArith { .. } => "the arithmetic expansion is not closed",
            InvalidCommandToken => "the command starts with an inappropriate token",
            MissingSeparator => "a separator is missing between the commands",
            FdOutOfRange => "the file descriptor is too large",
            InvalidIoLocation => "the I/O location prefix is not valid",
            MissingRedirOperand => "the redirection operator is missing its operand",
            MissingHereDocDelimiter => "the here-document operator is missing its delimiter",
            MissingHereDocContent => "content of the here-document is missing",
            UnclosedHereDocContent { .. } => {
                "the delimiter to close the here-document content is missing"
            }
            UnclosedArrayValue { .. } => "the array assignment value is not closed",
            UnopenedGrouping
            | UnopenedSubshell
            | UnopenedLoop
            | UnopenedDoClause
            | UnopenedIf
            | UnopenedCase
            | InAsCommandName
            | CloseBracketBracketAsCommandName => "the compound command delimiter is unmatched",
            UnclosedGrouping { .. } => "the grouping is not closed",
            EmptyGrouping => "the grouping is missing its content",
            UnclosedSubshell { .. } => "the subshell is not closed",
            EmptySubshell => "the subshell is missing its content",
            UnclosedDoClause { .. } => "the `do` clause is missing its closing `done`",
            EmptyDoClause => "the `do` clause is missing its content",
            MissingForName => "the variable name is missing in the `for` loop",
            InvalidForName => "the variable name is invalid",
            InvalidForValue => "the operator token is invalid in the word list of the `for` loop",
            MissingForBody { .. } => "the `for` loop is missing its `do` clause",
            UnclosedWhileClause { .. } => "the `while` loop is missing its `do` clause",
            EmptyWhileCondition => "the `while` loop is missing its condition",
            UnclosedUntilClause { .. } => "the `until` loop is missing its `do` clause",
            EmptyUntilCondition => "the `until` loop is missing its condition",
            IfMissingThen { .. } => "the `if` command is missing the `then` clause",
            EmptyIfCondition => "the `if` command is missing its condition",
            EmptyIfBody => "the `if` command is missing its body",
            ElifMissingThen { .. } => "the `elif` clause is missing the `then` clause",
            EmptyElifCondition => "the `elif` clause is missing its condition",
            EmptyElifBody => "the `elif` clause is missing its body",
            EmptyElse => "the `else` clause is missing its content",
            UnclosedIf { .. } => "the `if` command is missing its closing `fi`",
            MissingCaseSubject => "the subject is missing after `case`",
            InvalidCaseSubject => "the `case` command subject is not a valid word",
            MissingIn { .. } => "`in` is missing in the `case` command",
            UnclosedPatternList => "the pattern list is not properly closed by a `)`",
            MissingPattern => "a pattern is missing in the `case` command",
            InvalidPattern => "the pattern is not a valid word token",
            #[allow(deprecated, reason = "for backward compatible API")]
            EsacAsPattern => "`esac` cannot be the first of a pattern list",
            UnclosedCase { .. } => "the `case` command is missing its closing `esac`",
            UnmatchedParenthesis => "`)` is missing after `(`",
            MissingFunctionBody => "the function body is missing",
            InvalidFunctionBody => "the function body must be a compound command",
            MissingPipeline(AndOr::AndThen) => "a command is missing after `&&`",
            MissingPipeline(AndOr::OrElse) => "a command is missing after `||`",
            DoubleNegation => "`!` cannot be used twice in a row",
            BangAfterBar => "`!` cannot be used in the middle of a pipeline",
            MissingCommandAfterBang => "a command is missing after `!`",
            MissingCommandAfterBar => "a command is missing after `|`",
            RedundantToken => "there is a redundant token",
            IncompleteControlEscape => "the control escape is incomplete",
            IncompleteControlBackslashEscape => "the control-backslash escape is incomplete",
            InvalidControlEscape => "the control escape is invalid",
            OctalEscapeOutOfRange => "the octal escape is out of range",
            IncompleteHexEscape => "the hexadecimal escape is incomplete",
            IncompleteShortUnicodeEscape | IncompleteLongUnicodeEscape => {
                "the Unicode escape is incomplete"
            }
            UnicodeEscapeOutOfRange => "the Unicode escape is out of range",
            UnsupportedFunctionDefinitionSyntax
            | UnsupportedDoubleBracketCommand
            | UnsupportedNamespaceCommand
            | UnsupportedSelectCommand
            | UnsupportedProcessRedirection => "unsupported syntax",
            UnsupportedArithmeticCommand => "`((` is ambiguous at the start of a command",
            UnsupportedExtendedGlob => "`!(` is ambiguous at the start of a command",
            NonPortableCaseTerminator(_) => "the case terminator is not portable",
            NonPortableRedirOperator(_) => "the redirection operator is not portable",
            IoTokenAsRedirOperand => {
                "the redirection operand is missing because the token belongs to the next redirection"
            }
            MissingSeparatorBeforeReservedWord => "a separator is missing before the reserved word",
            ColonSuffixedCommandName => "the command name is not portable",
            NonPortableEscape => "the escape sequence is not portable",
            TooLongHexEscape => "more than two hexadecimal digits follow `\\x`",
            NonPortableForName => "the for loop variable name is not portable",
            NonPortableFunctionName => "the function name is not portable",
            NonPortableAssignmentName => "the assignment name is not portable",
            SpecialBuiltinFunctionName => {
                "the function name is the same as a special built-in utility"
            }
            NonPortableParamModifier => "the parameter expansion is not portable",
        }
    }

    /// Returns a label for annotating the error location.
    #[must_use]
    pub fn label(&self) -> &'static str {
        use SyntaxError::*;
        match self {
            IncompleteEscape => "expected an escaped character after the backslash",
            InvalidEscape => "invalid escape sequence",
            UnclosedParen { .. }
            | UnclosedCommandSubstitution { .. }
            | UnclosedArrayValue { .. }
            | UnclosedSubshell { .. }
            | UnclosedPatternList
            | UnmatchedParenthesis => "expected `)`",
            EmptyGrouping
            | EmptySubshell
            | EmptyDoClause
            | EmptyWhileCondition
            | EmptyUntilCondition
            | EmptyIfCondition
            | EmptyIfBody
            | EmptyElifCondition
            | EmptyElifBody
            | EmptyElse
            | MissingPipeline(_)
            | MissingCommandAfterBang
            | MissingCommandAfterBar => "expected a command",
            InvalidForValue | MissingCaseSubject | InvalidCaseSubject | MissingPattern
            | InvalidPattern => "expected a word",
            UnclosedSingleQuote { .. } | UnclosedDollarSingleQuote { .. } => "expected `'`",
            UnclosedDoubleQuote { .. } => "expected `\"`",
            UnclosedParam { .. } | UnclosedGrouping { .. } => "expected `}`",
            EmptyParam => "expected a parameter name",
            InvalidParam => "not a valid named or positional parameter",
            InvalidModifier => "broken modifier",
            MultipleModifier => "conflicting modifier",
            UnclosedBackquote { .. } => "expected '`'",
            UnclosedArith { .. } => "expected `))`",
            InvalidCommandToken => "does not begin a valid command",
            MissingSeparator => "expected `;` or `&` before this token",
            FdOutOfRange => "unsupported file descriptor",
            InvalidIoLocation => "unsupported I/O location prefix",
            MissingRedirOperand => "expected a redirection operand",
            MissingHereDocDelimiter => "expected a delimiter word",
            MissingHereDocContent => "content not found",
            UnclosedHereDocContent { .. } => "missing delimiter",
            UnopenedGrouping => "no grouping command to close",
            UnopenedSubshell => "no subshell to close",
            UnopenedLoop => "not in a loop",
            UnopenedDoClause => "no `do` clause to close",
            UnclosedDoClause { .. } => "expected `done`",
            MissingForName => "expected a variable name",
            InvalidForName => "not a valid variable name",
            MissingForBody { .. } | UnclosedWhileClause { .. } | UnclosedUntilClause { .. } => {
                "expected `do ... done`"
            }
            IfMissingThen { .. } | ElifMissingThen { .. } => "expected `then ... fi`",
            UnopenedIf => "not in an `if` command",
            UnclosedIf { .. } => "expected `fi`",
            MissingIn { .. } => "expected `in`",
            #[allow(deprecated, reason = "for backward compatible API")]
            EsacAsPattern => "needs quoting",
            UnopenedCase => "not in a `case` command",
            UnclosedCase { .. } => "expected `esac`",
            MissingFunctionBody | InvalidFunctionBody => "expected a compound command",
            InAsCommandName | CloseBracketBracketAsCommandName => {
                "cannot be used as a command name"
            }
            DoubleNegation => "only one `!` allowed",
            BangAfterBar => "`!` not allowed here",
            RedundantToken => "unexpected token",
            IncompleteControlEscape => r"expected a control character after `\c`",
            IncompleteControlBackslashEscape => r"expected another backslash after `\c\`",
            InvalidControlEscape => "not a valid control character",
            OctalEscapeOutOfRange => r"expected a value between \0 and \377",
            IncompleteHexEscape => r"expected a hexadecimal digit after `\x`",
            IncompleteShortUnicodeEscape => r"expected a hexadecimal digit after `\u`",
            IncompleteLongUnicodeEscape => r"expected a hexadecimal digit after `\U`",
            UnicodeEscapeOutOfRange => "not a valid Unicode scalar value",
            UnsupportedFunctionDefinitionSyntax => "the `function` keyword is not yet supported",
            UnsupportedDoubleBracketCommand => "the `[[ ... ]]` command is not yet supported",
            UnsupportedNamespaceCommand => "the `namespace` command is not yet supported",
            UnsupportedSelectCommand => "the `select` command is not yet supported",
            UnsupportedProcessRedirection => "process redirection is not yet supported",
            UnsupportedArithmeticCommand => {
                "other shells read this as an arithmetic command; insert a space for nested subshells"
            }
            UnsupportedExtendedGlob => {
                "other shells read this as an extended glob; insert a space after `!` for a negated subshell"
            }
            NonPortableCaseTerminator(Operator::SemicolonSemicolonAnd) => {
                "`;;&` is not a POSIX case terminator"
            }
            NonPortableCaseTerminator(Operator::SemicolonBar) => {
                "`;|` is not a POSIX case terminator"
            }
            NonPortableCaseTerminator(_) => "not a POSIX case terminator",
            NonPortableRedirOperator(RedirOp::Pipe) => "`>>|` is not a POSIX redirection operator",
            NonPortableRedirOperator(RedirOp::String) => {
                "`<<<` is not a POSIX redirection operator"
            }
            NonPortableRedirOperator(_) => "not a POSIX redirection operator",
            IoTokenAsRedirOperand => "add a space before the following redirection operator",
            MissingSeparatorBeforeReservedWord => {
                "insert `;` or a newline before this reserved word"
            }
            ColonSuffixedCommandName => "a command name ending with `:` is reserved by POSIX",
            NonPortableEscape => "not a POSIX escape sequence",
            TooLongHexEscape => "use at most two hexadecimal digits",
            NonPortableForName => "not a POSIX variable name",
            NonPortableFunctionName => "not a POSIX name",
            NonPortableAssignmentName => "not a POSIX variable name",
            SpecialBuiltinFunctionName => "conflicts with a special built-in utility",
            NonPortableParamModifier => {
                "POSIX leaves this parameter/modifier combination unspecified"
            }
        }
    }

    /// Returns footnotes that supplement the error message.
    ///
    /// Each item pairs a [`FootnoteType`] with its text. The footnotes are to
    /// be rendered after the error's source code snippet. The slice is empty
    /// for errors that need no footnote.
    #[must_use]
    pub fn footnotes(&self) -> &'static [(FootnoteType, &'static str)] {
        use SyntaxError::*;
        match self {
            BangAfterBar => &[(
                FootnoteType::Suggestion,
                // TODO Replace with a span-attached patch (SpanRole::Patch)
                "surround the pipeline component in a grouping: `{ ! ...; }`",
            )],
            UnsupportedArithmeticCommand
            | UnsupportedExtendedGlob
            | NonPortableCaseTerminator(_)
            | NonPortableRedirOperator(_)
            | IoTokenAsRedirOperand
            | MissingSeparatorBeforeReservedWord
            | ColonSuffixedCommandName
            | NonPortableEscape
            | TooLongHexEscape
            | NonPortableForName
            | NonPortableFunctionName
            | NonPortableAssignmentName
            | SpecialBuiltinFunctionName
            | NonPortableParamModifier => &[(
                FootnoteType::Note,
                "this error is reported because the `portable` shell option is enabled",
            )],
            _ => &[],
        }
    }

    /// Returns a location related with the error cause and a message describing
    /// the location.
    #[must_use]
    pub fn related_location(&self) -> Option<(&Location, &'static str)> {
        use SyntaxError::*;
        match self {
            UnclosedParen { opening_location }
            | UnclosedSubshell { opening_location }
            | UnclosedArrayValue { opening_location } => {
                Some((opening_location, "the opening parenthesis was here"))
            }
            UnclosedSingleQuote { opening_location }
            | UnclosedDoubleQuote { opening_location }
            | UnclosedDollarSingleQuote { opening_location } => {
                Some((opening_location, "the opening quote was here"))
            }
            UnclosedParam { opening_location } => {
                Some((opening_location, "the parameter started here"))
            }
            UnclosedCommandSubstitution { opening_location } => {
                Some((opening_location, "the command substitution started here"))
            }
            UnclosedBackquote { opening_location } => {
                Some((opening_location, "the opening backquote was here"))
            }
            UnclosedArith { opening_location } => {
                Some((opening_location, "the arithmetic expansion started here"))
            }
            UnclosedHereDocContent { redir_op_location } => {
                Some((redir_op_location, "the redirection operator was here"))
            }
            UnclosedGrouping { opening_location } => {
                Some((opening_location, "the opening brace was here"))
            }
            UnclosedDoClause { opening_location } => {
                Some((opening_location, "the `do` clause started here"))
            }
            MissingForBody { opening_location } => {
                Some((opening_location, "the `for` loop started here"))
            }
            UnclosedWhileClause { opening_location } => {
                Some((opening_location, "the `while` loop started here"))
            }
            UnclosedUntilClause { opening_location } => {
                Some((opening_location, "the `until` loop started here"))
            }
            IfMissingThen { if_location }
            | UnclosedIf {
                opening_location: if_location,
            } => Some((if_location, "the `if` command started here")),
            ElifMissingThen { elif_location } => {
                Some((elif_location, "the `elif` clause started here"))
            }
            MissingIn { opening_location } | UnclosedCase { opening_location } => {
                Some((opening_location, "the `case` command started here"))
            }
            _ => None,
        }
    }
}

/// Types of errors that may happen in parsing
#[derive(Clone, Debug, Error)]
#[error("{}", self.message())]
pub enum ErrorCause {
    /// Error in an underlying input function
    Io(#[from] Rc<std::io::Error>),
    /// Syntax error
    Syntax(#[from] SyntaxError),
}

impl PartialEq for ErrorCause {
    fn eq(&self, other: &Self) -> bool {
        match (self, other) {
            (ErrorCause::Syntax(e1), ErrorCause::Syntax(e2)) => e1 == e2,
            _ => false,
        }
    }
}

impl ErrorCause {
    /// Returns an error message describing the error cause.
    #[must_use]
    pub fn message(&self) -> Cow<'static, str> {
        use ErrorCause::*;
        match self {
            Io(e) => format!("cannot read commands: {e}").into(),
            Syntax(e) => e.message().into(),
        }
    }

    /// Returns a label for annotating the error location.
    #[must_use]
    pub fn label(&self) -> &'static str {
        use ErrorCause::*;
        match self {
            Io(_) => "the command could be read up to here",
            Syntax(e) => e.label(),
        }
    }

    /// Returns footnotes that supplement the error message.
    ///
    /// See [`SyntaxError::footnotes`] for details.
    #[must_use]
    pub fn footnotes(&self) -> &'static [(FootnoteType, &'static str)] {
        use ErrorCause::*;
        match self {
            Io(_) => &[],
            Syntax(e) => e.footnotes(),
        }
    }

    /// Returns a location related with the error cause and a message describing
    /// the location.
    #[must_use]
    pub fn related_location(&self) -> Option<(&Location, &'static str)> {
        use ErrorCause::*;
        match self {
            Io(_) => None,
            Syntax(e) => e.related_location(),
        }
    }
}

impl From<std::io::Error> for ErrorCause {
    fn from(e: std::io::Error) -> ErrorCause {
        ErrorCause::from(Rc::new(e))
    }
}

/// Explanation of a failure in parsing
#[derive(Clone, Debug, Error, PartialEq)]
#[error("{cause}")]
pub struct Error {
    pub cause: ErrorCause,
    pub location: Location,
}

impl Error {
    /// Returns a report for the error.
    ///
    /// This method constructs a [`Report`] from the error's cause and location.
    /// The result includes information obtained from [`ErrorCause::message`],
    /// [`ErrorCause::label`], [`ErrorCause::footnotes`], and
    /// [`ErrorCause::related_location`].
    #[must_use]
    pub fn to_report(&self) -> Report<'_> {
        let mut report = Report::new();
        report.r#type = ReportType::Error;
        report.title = self.cause.message();
        report.snippets = Snippet::with_primary_span(&self.location, self.cause.label().into());

        if let Some((location, label)) = self.cause.related_location() {
            let label = label.into();
            let span = Span {
                range: location.byte_range(),
                role: SpanRole::Supplementary { label },
            };
            add_span(&location.code, span, &mut report.snippets);
        }

        report.footnotes.extend(
            self.cause
                .footnotes()
                .iter()
                .map(|&(r#type, label)| Footnote {
                    r#type,
                    label: label.into(),
                }),
        );

        report
    }
}

/// Converts the error into a report by calling [`Error::to_report`].
impl<'a> From<&'a Error> for Report<'a> {
    #[inline(always)]
    fn from(error: &'a Error) -> Self {
        error.to_report()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::source::Code;
    use crate::source::Source;
    use std::assert_matches;
    use std::num::NonZeroU64;
    use std::rc::Rc;

    #[test]
    fn display_for_error() {
        let code = Rc::new(Code {
            value: "".to_string().into(),
            start_line_number: NonZeroU64::new(1).unwrap(),
            source: Source::Unknown.into(),
        });
        let location = Location { code, range: 0..42 };
        let error = Error {
            cause: SyntaxError::MissingHereDocDelimiter.into(),
            location,
        };
        assert_eq!(
            error.to_string(),
            "the here-document operator is missing its delimiter"
        );
    }

    #[test]
    fn from_error_for_report() {
        let code = Rc::new(Code {
            value: "!!!".to_string().into(),
            start_line_number: NonZeroU64::new(1).unwrap(),
            source: Source::Unknown.into(),
        });
        let error = Error {
            cause: SyntaxError::MissingHereDocDelimiter.into(),
            location: Location { code, range: 0..42 },
        };

        let report = Report::from(&error);

        assert_eq!(report.r#type, ReportType::Error);
        assert_eq!(
            report.title,
            "the here-document operator is missing its delimiter"
        );
        assert_eq!(report.snippets.len(), 1);
        assert_eq!(*report.snippets[0].code.value.borrow(), "!!!");
        assert_eq!(report.snippets[0].spans.len(), 1);
        assert_eq!(report.snippets[0].spans[0].range, 0..3);
        assert_matches!(
            &report.snippets[0].spans[0].role,
            SpanRole::Primary { label } if label == "expected a delimiter word"
        );
        assert_eq!(report.footnotes, []);
    }

    #[test]
    fn footnotes_for_syntax_error() {
        // Errors caused by the `portable` option have a note.
        let note = (
            FootnoteType::Note,
            "this error is reported because the `portable` shell option is enabled",
        );
        assert_eq!(SyntaxError::IoTokenAsRedirOperand.footnotes(), [note]);
        assert_eq!(
            SyntaxError::UnsupportedArithmeticCommand.footnotes(),
            [note]
        );
        // A suggestion is offered for `!` in the middle of a pipeline.
        assert_eq!(
            SyntaxError::BangAfterBar.footnotes(),
            [(
                FootnoteType::Suggestion,
                "surround the pipeline component in a grouping: `{ ! ...; }`"
            )]
        );
        // Errors unrelated to the `portable` option have no footnote.
        assert_eq!(SyntaxError::MissingHereDocDelimiter.footnotes(), []);
        // Unconditionally unsupported syntax is not caused by the option.
        assert_eq!(SyntaxError::UnsupportedDoubleBracketCommand.footnotes(), []);
    }

    #[test]
    fn report_has_footnote_for_portable_error() {
        let code = Rc::new(Code {
            value: "((:))".to_string().into(),
            start_line_number: NonZeroU64::new(1).unwrap(),
            source: Source::Unknown.into(),
        });
        let error = Error {
            cause: SyntaxError::UnsupportedArithmeticCommand.into(),
            location: Location { code, range: 0..2 },
        };

        let report = Report::from(&error);

        assert_eq!(report.footnotes.len(), 1);
        assert_eq!(report.footnotes[0].r#type, FootnoteType::Note);
        assert_eq!(
            report.footnotes[0].label,
            "this error is reported because the `portable` shell option is enabled"
        );
    }
}