%YAML 1.2 --- # http://www.sublimetext.com/docs/3/syntax.html name: friendly interactive shell (fish) file_extensions: - fish first_line_match: '^#!.*\b(fish)|^#\s*-\*-[^*]*mode:\s*shell-script[^*]*-\*-' scope: source.shell.fish contexts: main: - include: comment-external - include: line-continuation - match: \)|end comment: In an ideal world, command-call-standard would be performing this match because fish highlights the strings which follow as arguments. But we can't do that in a tmLanguage push: - meta_scope: meta.function-call.fish invalid.illegal.function-call.fish - match: '(?=[\s;&)|<>])' pop: true - match: (?=\S) comment: Anonymous scope - Base scope pipeline goes up until one of the definitive ends (newline and ';') or the sequences that could be an end if we're actually inside a $self scope right now (')' and "end") push: - match: \n|(;)|(?=\))|(?=end) captures: 1: meta.function-call.operator.fish keyword.operator.control.fish pop: true - match: '(?:[&|]|(?:[0-9]+)?(?:<|>>?|\^\^?))' comment: Match operators ('&', pipe, and redirect) which cannot start a pipeline because they must be consumed within or after a pipeline scope: invalid.illegal.operator.fish - include: comment-internal-end - match: (?=\S) comment: The reason we match '&' here is because we explicitly require it come after a command (unlike ';' which can be alone on a line) push: - match: '(?=[\n;)])|(&)' captures: 1: meta.function-call.operator.fish keyword.operator.control.fish pop: true - include: pipeline argument: - match: '(?![\s;&)|<>^])' comment: End arg if it precedes whitespace or operators (excluding stderr redirect '^' due to a fish quirk) push: - match: '(?=[\s;&)|<>])' pop: true - match: \% comment: Process expansion only occurs if the '%' is at the front of the argument, and continues for the entire argument captures: 0: meta.string.unquoted.fish punctuation.definition.process.fish push: - meta_scope: meta.parameter.argument.process-expansion.fish - match: '(?=[\s;&)|<>])' pop: true - match: '(?:self|last)(?=$|[\s;&)|<>])' comment: Match special process names. By a convention that I'm making up, scope them as a type of variable scope: meta.string.unquoted.fish variable.language.fish - include: parameter-patterns - match: '(?:[+-]?)(?:[0-9]+\.?[0-9]*|[0-9]*\.?[0-9]+)(?=$|[\s;&)|<>])' comment: Treat a sequence of integers (with possible sign and decimal separator) as a standalone constant. Do this separate to the scope: meta.parameter.argument.numeric.fish meta.string.unquoted.fish constant.numeric.fish - match: '(?![\s($])' comment: This scope can be used by plugins to locate arguments which don't *start* with command substitution or variable expansion and may directly resolve to file paths. Of course, they could have command substitution or variable expansion further on in them, but looking ahead for that too is nontrivial push: - meta_scope: meta.parameter.argument.path.fish - match: '(?=[\s;&)|<>])' pop: true - match: \~ comment: Home directory expansion only occurs if the '~' is at the front of the argument, so check it first scope: meta.string.unquoted.fish keyword.operator.tilde.fish - include: parameter-patterns - match: (?!\s) comment: Use standard parameter patterns for whatever doesn't match the above push: - meta_scope: meta.parameter.argument.fish - match: '(?=[\s;&)|<>])' pop: true - include: parameter-patterns command-call-meta: - match: '(builtin|command|exec)\b(?!\s+[-&|])' comment: These meta commands force the parameter to behave as a standard command. They stop at piping captures: 1: support.function.fish push: - match: |- (?x) (?# Look ahead for control operations after whitespace) (?=\s* (?: (?# Find simple control operations) [\n;&)] | (?# Find piping) (?:(?:[0-9]+)?(?:<|>>?|\^\^?))?\| ) ) pop: true - include: line-continuation - include: command-call-standard - match: '(not)\b(?!\s+[-&|])' comment: This meta command acts as a unary operator on the command to the right, which can also be a meta command. It only applies to one command and stops at piping captures: 1: keyword.operator.word.fish push: - match: |- (?x) (?# Look ahead for control operations after whitespace) (?=\s* (?: (?# Find simple control operations) [\n;&)] | (?# Find piping) (?:(?:[0-9]+)?(?:<|>>?|\^\^?))?\| ) ) pop: true - include: line-continuation - include: command-call-meta - include: command-call-standard command-call-standard: - match: '\#' comment: A command call can't be a comment, but this match will only be satisfied if the command is first after a pipe because comments are otherwise consumed earlier push: - meta_scope: invalid.illegal.function-call.fish - match: '(?=[\n)])' pop: true - match: "(?:[&|<>^])" comment: Match an operator which cannot start a command call but does not stop the next characters from being interpreted as a command scope: invalid.illegal.operator.fish - match: (?=\S) comment: Anonymous scope - A complete command comprising a name element and optional parameter, redirection, and comment elements push: - match: |- (?x) (?# Look ahead for operators) (?= (?: (?# Find a control operator) [\n;&)] | (?# Find a pipe operator) (?:(?:[0-9]+)?(?:<|>>?|\^\^?))?\| ) ) pop: true - match: '(?![\s<>^%])' comment: Anonymous scope - A name or block element. If a block is found, everything up to the `end` command is captured here. Note that redirection and process expansion can't start the element push: - match: '(?=[\s;&)|<>])' pop: true - include: command-call-standard-block - match: '\[(?=[\s<>]|\\\n)' comment: "Look for the alternate form of test, which uses a matching pair of '[' ']'" captures: 0: support.function.test.begin.fish push: - match: '(\])|(\n|[;&)|].*)' captures: 1: support.function.test.end.fish 2: invalid.illegal.function-call.fish pop: true - include: line-continuation - include: parameter - include: redirection - match: '(?:break|continue|return)(?=[\s;&)|<>])' comment: Look for loop/function control commands. We perform no checking on the validity of their scope (because only allowing them in the correct scope won't work if they are used within if-blocks) or parameters (because fish does that during execution not parsing) captures: 0: keyword.control.conditional.fish - match: (?!\s) comment: Anonymous scope - A generic name element push: - match: '(?=[\s;&)|<>])' pop: true - match: (?=\() comment: fish would match the whole command name invalid if there was a command substitution anywhere in it, but we can't look ahead that effectively push: - meta_scope: invalid.illegal.function-call.fish - match: '(?=[\s;&)|<>])' pop: true - match: \( push: - match: '\)|(?=[\n;&)|<>])' pop: true - match: (?!\s) comment: Otherwise, treat the element as a fraction of a name made of arbitrary strings (which breaks at an escaped newline) push: - meta_scope: variable.function.fish - match: '(?=[\s;&()|<>])' pop: true - match: \$ comment: The string scope explicitly forbids '$' so that the argument rule can pick it up as a variable expansion, but '$' is treated as a literal in command names, so we have to match it separately scope: meta.string.unquoted.fish - include: string - match: \% comment: A command name can't begin with a process expansion operator (however the variable expansion operator '$' is allowed) push: - meta_scope: invalid.illegal.function-call.fish - match: '(?=[\s;&)|<>])' pop: true - include: string - include: redirection - match: '(?:[^\n\S]+)' comment: Match any whitespace characters that aren't the newline push: - match: |- (?x) (?# Look ahead for operators) (?= (?: (?# Find a control operator) [\n;&)] | (?# Find a pipe operator) (?:(?:[0-9]+)?(?:<|>>?|\^\^?))?\| ) ) pop: true - match: '(?!--[\s;&)|<>])' comment: A list of elements that does not start with an end-of-options parameter push: - match: |- (?x) (?# Look ahead for operators or the end of options) (?= (?: (?# Find a control operator) [\n;&)] | (?# Find a pipe operator) (?:(?:[0-9]+)?(?:<|>>?|\^\^?))?\| | (?# Find a double hyphen) --[\s;&)|<>] ) ) pop: true - include: line-continuation - include: comment-internal-end - include: redirection - include: parameter - match: '(?=--[\s;&)|<>])' comment: A list of elements that starts with an end-of-options parameter push: - match: |- (?x) (?# Look ahead for operators) (?= (?: (?# Find a control operator) [\n;&)] | (?# Find a pipe operator) (?:(?:[0-9]+)?(?:<|>>?|\^\^?))?\| ) ) pop: true - match: '(?=--[\s;&)|<>])' comment: Contain just the end-of-options parameter and give it the normal scope push: - match: '(?=[\s;&)|<>])' pop: true - include: parameter - match: (?=\s) comment: A list of elements (now forcibly using arguments) push: - match: |- (?x) (?# Look ahead for operators) (?= (?: (?# Find a control operator) [\n;&)] | (?# Find a pipe operator) (?:(?:[0-9]+)?(?:<|>>?|\^\^?))?\| ) ) pop: true - include: line-continuation - include: comment-internal-end - include: redirection - include: argument command-call-standard-block: - match: '(begin|while|if|for|switch|function)\s*([&|<>])' comment: Block commands cannot be backgrounded, piped, or redirected captures: 1: variable.function.fish 2: invalid.illegal.operator.fish - match: (begin)\s*(\)) comment: The begin command uniquely cannot be the last command in a command substitution captures: 1: variable.function.fish 2: invalid.illegal.operator.fish - match: 'begin(?=\s*$|\s*[\n;]|\s+[^\s-])' comment: The begin command can be alone on a line or followed by any command that doesn't start with a '-'. If a '-' is seen it shouldn't be treated as a block captures: 0: keyword.control.conditional.fish push: - meta_scope: meta.block.begin.fish - match: 'end(?=$|[\s;&)|<>])' captures: 0: keyword.control.conditional.fish pop: true - include: main - match: '(?=while\s+[^\s;)-])' comment: If the command name is followed by a valid string (not anything that closes the scope or a string starting with a '-') then open the scope push: - meta_scope: meta.block.while.fish - match: 'end(?=$|[\s;&)|<>])' captures: 0: keyword.control.conditional.fish pop: true - match: while comment: Anonymous scope - Capture the command name we know is there, include a single instance of a pipeline, and end when an operator is seen captures: 0: keyword.control.conditional.fish push: - match: '\s*(?=[\n;&)])' pop: true - include: line-continuation - include: pipeline - match: '\n|(;)|([&)])' comment: Anonymous scope - Capture the operator we know is there, include the base scope, and end when an `end` command is seen captures: 1: keyword.operator.control.fish 2: invalid.illegal.operator.fish push: - match: '(?=end(?:$|[\s;&)|<>]))' pop: true - include: main - match: '(?=if\s+[^\s;)-])' comment: If the command name is followed by a valid string (not anything that closes the scope or a string starting with a '-') then open the scope push: - meta_scope: meta.block.if.fish - match: 'end(?=$|[\s;&)|<>])' captures: 0: keyword.control.conditional.fish pop: true - include: command-call-standard-block-if-internal - match: '(?=for\s+[^\s;)-])' comment: If the command name is followed by a valid string (not anything that closes the scope or a string starting with a '-') then open the scope push: - meta_scope: meta.block.for-in.fish - match: 'end(?=$|[\s;&)|<>])' captures: 0: keyword.control.conditional.fish pop: true - match: (for)(?:\s+) comment: Anonymous scope - Capture the command name we know is there, include a single instance of a parameter (the varname), and end when the whitespace after the varname is captured captures: 1: keyword.control.conditional.fish push: - match: \s+ pop: true - include: line-continuation - include: parameter - match: \S+ comment: Capture anything that a parameter explicitly rejects, which is mostly operators scope: invalid.illegal.operator.fish - include: line-continuation - match: in(?=\s) comment: Anonymous scope - Capture the command name which might be there, include an arbitrary number of arguments, and end when the control operator is seen captures: 0: keyword.control.conditional.fish push: - match: '\s*(?=[\n;&)])' pop: true - include: line-continuation - include: comment-internal-end - include: argument - match: '\n|(;)|([&)])' comment: Anonymous scope - Capture the operator we know is there, include the base scope, and end when an `end` command is seen captures: 1: keyword.operator.control.fish 2: invalid.illegal.operator.fish push: - match: '(?=end(?:$|[\s;&)|<>]))' pop: true - include: main - match: '\S+?(?=[\s;&)])' comment: Anything beside line continuation, "in", or a control operator is invalid scope: invalid.illegal.function-call.fish - match: '(?=switch\s+[^\s;)-])' comment: If the command name is followed by a valid string (not anything that closes the scope or a string starting with a '-') then open the scope push: - meta_scope: meta.block.switch.fish - match: 'end(?=$|[\s;&)|<>])' captures: 0: keyword.control.conditional.fish pop: true - match: (?=switch) comment: Anonymous scope - Match the valid part of the switch statement, then look for an invalid part push: - match: '\s*(?=[\n;&)])' pop: true - match: (switch)(?:\s+) comment: Anonymous scope - Capture the command name we know is there, include a single instance of a parameter (the value), and end when whitespace or a control operator is seen captures: 1: keyword.control.conditional.fish push: - match: '(?=[\s;&)])' pop: true - include: line-continuation - include: parameter - match: \S+ comment: Capture anything that a parameter explicitly rejects, which is mostly operators scope: invalid.illegal.operator.fish - match: \s+ comment: Anonymous scope - Capture whitespace which might be there, match any non-control-operator strings as invalid, and end when a control operator is seen push: - match: '(?=[\n;&)])' pop: true - match: '\S+?(?=[\s;&)])' scope: invalid.illegal.string.fish - match: '\n|(;)|([&)])' comment: Anonymous scope - Capture the operator we know is there, include the base scope, and end when an `end` command is seen captures: 1: keyword.operator.control.fish 2: invalid.illegal.operator.fish push: - match: '(?=end(?:$|[\s;&)|<>]))' pop: true - match: 'case(?=[\s;&)])' comment: Anonymous scope - Capture the command name which might be there, include an arbitrary number of arguments, and end when the control operator is captured captures: 0: keyword.control.conditional.fish push: - match: '\n|(;)|([&)])' captures: 1: keyword.operator.control.fish 2: invalid.illegal.operator.fish pop: true - include: line-continuation - include: comment-internal-end - include: argument - match: '\S+?(?=[\s;&)])' comment: Anything else (eg, redirection) is illegal scope: invalid.illegal.operator.fish - include: main - match: '(?=function\s+[^\s;)-])' comment: If the command name is followed by a valid string (not anything that closes the scope or a string starting with a '-') then open the scope push: - meta_scope: meta.block.function.fish - match: 'end(?=$|[\s;&)|<>])' captures: 0: keyword.control.conditional.fish pop: true - match: (?=function) comment: Anonymous scope - Match the defined name of the function statement, then look for further parameters push: - match: '\s*(?=[\n;&)])' pop: true - match: (function)(?:\s+) comment: Anonymous scope - Capture the command name we know is there, include a single instance of a parameter (the value), and end when whitespace or a control operator is seen captures: 1: keyword.control.conditional.fish push: - match: '(?=[\s;&)])' pop: true - include: line-continuation - match: "(?:[()|<>])" push: - meta_scope: invalid.illegal.string.fish - match: '(?=[\s;&)])' pop: true - match: (?!\\\n) comment: Anonymous scope - Start when an escaped newline isn't present, and end when whitespace or an operator is seen push: - match: '(?=[\s;&()|<>])' pop: true - match: (?!\s) push: - meta_scope: entity.name.function.fish - match: '(?=[\s;&()|<>])' pop: true - include: parameter - match: \s+ comment: Anonymous scope - Capture whitespace which might be there, then match anything normal for a command call push: - match: '(?=[\n;&)])' pop: true - include: line-continuation - include: comment-internal-end - include: redirection - include: parameter - match: '\n|(;)|([&)])' comment: Anonymous scope - Capture the operator we know is there, include the base scope, and end when an `end` command is seen captures: 1: keyword.operator.control.fish 2: invalid.illegal.operator.fish push: - match: '(?=end(?:$|[\s;&)|<>]))' pop: true - include: main command-call-standard-block-if-internal: - match: '(?=if(?:\s*\n|\s+[^\s;]))' comment: Anonymous scope - Capture an `if` and the command up to the control operator, then capture from the control operator indefinitely push: - match: '(?=end(?:$|[\s;&)|<>]))' pop: true - match: if comment: Anonymous scope - Match the command name we know is there, include a single instance of a pipeline, and end when a control operator is seen captures: 0: keyword.control.conditional.fish push: - match: '\s*(?=[\n;&])' pop: true - include: line-continuation - include: pipeline - match: \n|(;)|(&) comment: Anonymous scope - Match the operator we know is there, then include the base scope or an `else` structure captures: 1: keyword.operator.control.fish 2: invalid.illegal.operator.fish push: - match: '(?=end(?:$|[\s;&)|<>]))' pop: true - match: '(?=else\s*[\s;])' comment: Anonymous scope - Capture an `else` up to the control operator or the start of an `if` structure, then match from the control operator indefinitely or match an `if` structure push: - match: '(?=end(?:$|[\s;&)|<>]))' pop: true - match: 'else(?=\s*[\s;])' comment: Anonymous scope - Match the `else` we know is there and any comment, and mark anything besides an `if` as illegal captures: 0: keyword.control.conditional.fish push: - match: '\s*(?=[\n;&]|if(?:\s*\n|\s+[^\s;]))' pop: true - include: line-continuation - include: comment-internal-end - match: '\S+?(?=[\s;&])' comment: Anything else is illegal scope: invalid.illegal.string.fish - match: \n|(;)|(&) comment: Anonymous scope - Match the operator which will be there if no `if` was seen, then include the base scope which marks further `else` commands as invalid captures: 1: keyword.operator.control.fish 2: invalid.illegal.operator.fish push: - match: '(?=end(?:$|[\s;&)|<>]))' pop: true - include: main - include: command-call-standard-block-if-internal - include: main command-substitution: - match: (?=\() comment: 'Capture "(...)" or "(...)[...]"' push: - match: '(?![\(\[])' pop: true - match: \( captures: 0: punctuation.section.parens.begin.fish push: - meta_scope: meta.parens.command-substitution.fish - match: \) captures: 0: punctuation.section.parens.end.fish pop: true - include: main - include: index-expansion comment-external: - match: '\#' comment: A full or inline comment outside of any command call captures: 0: punctuation.definition.comment.fish push: - meta_scope: comment.line.external.fish - match: \n pop: true comment-internal-end: - match: '\#' comment: An inline comment at the end of a command call. Does not consume the newline, thus allowing the command call to capture it and end captures: 0: punctuation.definition.comment.fish push: - meta_scope: comment.line.internal.end.fish - match: (?=\n) pop: true index-expansion: - match: '\[' comment: In other words, the anonymous scope which contains the variable and the index expansion parameter list should only be allowed to contain a single copy of each of those two things. We cannot enforce that without a scope stack. Our workaround is to allow an infinite number of these and hope the user can keep track of when there are too many captures: 0: punctuation.section.brackets.begin.fish push: - meta_scope: meta.brackets.index-expansion.fish - match: '\]' captures: 0: punctuation.section.brackets.end.fish pop: true - match: \.\. scope: keyword.operator.range.fish - include: command-substitution - include: variable-expansion - include: string-quoted - match: '(?:[+-]?[0-9]+)(?=[\s;&)|<>]|\]|\.\.)' scope: constant.numeric.fish - match: '(?![\s''"]|\.\.)' comment: 'Begin/end string as before with the addition of breaking at a '']'' or ".."' push: - match: '(?=[\s;&)|<>''"]|\]|\.\.)' pop: true - include: string-unquoted-patterns line-continuation: - match: (?=\\\n) comment: End when an unescaped newline is seen, the first character of a line isn't whitespace or a comment character or the escaped newline itself, or if the next character after some consumed whitespace isn't more whitespace or a comment character push: - match: '(?=\n)|^(?![\s\#\\])|\s(?![\s\#])' pop: true - match: \\\n scope: constant.character.escape - match: '\#' captures: 0: punctuation.definition.comment.fish push: - meta_scope: comment.line.continuation.fish - match: \n pop: true parameter: - match: '(?![\s;&)|<>^])' comment: See the argument rule for more general information on parameters push: - match: '(?=[\s;&)|<>])' pop: true - match: '(?:--)(?=[\s;&)|<>])' comment: End of options (parameter of just two hyphens) scope: meta.parameter.option.end.fish variable.parameter.fish punctuation.definition.option.end.fish meta.string.unquoted.fish - match: (?=--) comment: Long option (parameter starting with two hyphens) push: - meta_scope: meta.parameter.option.long.fish - match: '(?=[\s;&)|<>])' pop: true - match: (?:--) captures: 0: punctuation.definition.option.long.begin.fish meta.string.unquoted.fish push: - meta_scope: variable.parameter.fish - match: '(?=[\s;&)|<>]|=)' pop: true - include: command-substitution - match: (?=\$) push: - meta_scope: meta.string.unquoted.fish - match: (?!\$) pop: true - include: variable-expansion - include: string-quoted - match: '(?![''"])' push: - meta_scope: meta.string.unquoted.fish - match: '(?=[\s;&()|<>''"$]|\=)' pop: true - include: string-unquoted-patterns - match: (?:=) comment: Consume the '=' and then use standard parameter patterns as well as numerics captures: 0: variable.parameter.fish punctuation.definition.option.long.separator.fish meta.string.unquoted.fish push: - match: '(?=[\s;&)|<>])' pop: true - match: '(?:[+-]?)(?:[0-9]+\.?[0-9]*|[0-9]*\.?[0-9]+)(?=$|[\s;&)|<>])' scope: meta.string.unquoted.fish constant.numeric.fish - include: parameter-patterns - match: '(?:-)(?=[^\s;&)|<>])' comment: Short option (parameter starting with one hyphen) captures: 0: punctuation.definition.option.short.fish meta.string.unquoted.fish push: - meta_scope: meta.parameter.option.short.fish variable.parameter.fish - match: '(?=[\s;&)|<>])' pop: true - include: parameter-patterns - include: argument parameter-patterns: - include: command-substitution - match: (?=\$) comment: Give variable expansion the unquoted string scope push: - meta_scope: meta.string.unquoted.fish - match: (?!\$) pop: true - include: variable-expansion - include: string pipeline: - match: '(?:[&|]|(?:[0-9]+)?(?:<|>>?|\^\^?))' comment: Todo - Restructure pipeline so that this match isn't duplicated from the base scope, which it must also be for any other scopes which implement their own control operator consumption. Might require the unary operator commands to become an explicit recursive match (though we tried this once and it was more complicated than anything should be) scope: invalid.illegal.operator.fish - match: (and|or)\b(?!\s+-) comment: Todo - These commands cannot be followed by backgrounding, piping, or redirection alone. Add logic to catch these cases. It will be extensive... scope: meta.function-call.fish keyword.operator.word.fish - include: line-continuation - match: (not)\b(?!\s+-) comment: This is a hack for now, which allows nesting of 'not' and 'and'/'or' commands. A better solution will be explicit recursivity in these commands scope: meta.function-call.fish keyword.operator.word.fish - match: '(?:case|else|end)(?=[\s;&)|<>])' comment: Match a command which is illegal in the base scope scope: invalid.illegal.function-call.fish - match: '(?=[^\s#])' comment: Anonymous scope - Pipeline. Define a pipeline as either one command call, or multiple command calls linked by pipe operators ('|', '2>|', etc). The pipeline terminates at the first encounter of any control operator push: - match: '(\s*)(?=[\n;&)])' captures: 1: meta.function-call.fish pop: true - match: |- (?x) (?# Negative lookahead for piping) (?! (?:(?:[0-9]+)?(?:<|>>?|\^\^?))?\| ) comment: Match the first command of a potential pipeline push: - meta_scope: meta.function-call.fish - match: |- (?x) (?# Look ahead for operators after whitespace) (?=\s* (?: (?# Find a control operator) [\n;&)] | (?# Find a pipe operator) (?:(?:[0-9]+)?(?:<|>>?|\^\^?))?\| ) ) pop: true - include: command-call-meta - include: command-call-standard - match: |- (?x) (?# Look ahead for piping) (?= (?:(?:[0-9]+)?(?:<|>>?|\^\^?))?\| ) comment: Match a second or later command of a pipeline, starting with the connective piping push: - meta_scope: meta.function-call.fish - match: '(?=\s*[\n;&)])' pop: true - match: |- (?x) (?# Look ahead for piping followed by either control operators or piping) (?= (?:(?:[0-9]+)?(?:<|>>?|\^\^?))?\| \s* (?: $ | [\n;&)] | (?:(?:[0-9]+)?(?:<|>>?|\^\^?))?\| ) ) comment: Match a pipe not followed by a command, hence a malformed segment of the pipeline push: - match: '(?=\s*$|\s*[\n;&)])' pop: true - match: '(?:(?:[0-9]+)?(?:<|>|>>))?\|(?=\s*$|\s*[\n)])' comment: If the pipeline would end implicitly (ie, with a newline or close parenthesis), then mark the pipe itself invalid scope: invalid.illegal.operator.fish - match: |- (?x) (?# Consume valid piping; captures 1 2 3) (?:([0-9]+)?(<|>>?|\^\^?))?(\|) (?# Consume whitespace) \s* (?# Consume remainder; capture 4) (.*) comment: If the pipeline would end with an explicit operator or encounter a second set of piping, then mark the first set of piping as valid and beyond as invalid captures: 1: meta.pipe.fish constant.numeric.file-descriptor.fish 2: meta.pipe.fish keyword.operator.redirect.fish 3: meta.pipe.fish keyword.operator.pipe.fish 4: invalid.illegal.function-call.fish - match: '(?:([0-9]+)?(<|>>?|\^\^?))?(\|)' comment: Pick up a legitimate pipe scope: meta.pipe.fish captures: 1: constant.numeric.file-descriptor.fish 2: keyword.operator.pipe.redirect.fish 3: keyword.operator.pipe.fish - include: line-continuation - include: command-call-meta - include: command-call-standard redirection: - match: '(?=(?:[0-9]+(?:<|>|>>)|>>|\^\^|[<>^])\&)' comment: End at anything that would end a parameter, including redirections *if* they are *not* this same type of redirection (ie, have an '&'), in which case this scope stays open and we match the next one. The negative lookahead for <>^ at the end is to keep ST2 happy (not hanging) push: - match: '(?=[\s;&)|]|(?:[0-9]+(?:<|>|>>)|>>|\^\^|[<>^])(?![&<>^]))' pop: true - match: '(?:([0-9]+)(<|>|>>)|(>>|\^\^|[<>^]))(\&)\s*' comment: We have to try and catch an '&' here because if it is seen by the outer end match then it will be considered a valid operator and the redirection scope will immediately terminate captures: 1: constant.numeric.file-descriptor.fish 2: keyword.operator.redirect.fish 3: keyword.operator.redirect.fish 4: keyword.operator.redirect.dereference.fish push: - meta_scope: meta.redirection.fish - match: '(\&.*$)|(?![&\\])' captures: 1: invalid.illegal.file-descriptor.fish pop: true - include: line-continuation - match: (?=\\\n) push: - meta_scope: meta.redirection.fish - match: (?!\\\n) pop: true - include: line-continuation - match: (?=\() comment: Evaluates to a string which may be an integer push: - meta_scope: meta.redirection.fish - match: (?!\() pop: true - include: command-substitution - match: (?=\$) comment: Evaluates to a string which may be an integer push: - meta_scope: meta.redirection.fish - match: (?!\$) pop: true - include: variable-expansion - match: '(?=[''"])' comment: May be a quoted integer, which is allowed push: - meta_scope: meta.redirection.fish - match: '(?![''"])' pop: true - include: string-quoted - match: '(?:[0-9]+)(?=$|[\s;&)|<>])' scope: meta.redirection.file-descriptor.fish constant.numeric.file-descriptor.fish - match: '(?:-)(?=$|[\s;&)|<>])' scope: meta.redirection.file-descriptor.fish keyword.operator.redirect.close.fish - match: (?:\S+.*)$ comment: Anything else is illegal scope: meta.redirection.fish invalid.illegal.file-descriptor.fish - match: '(?=(?:[0-9]+(?:<|>|>>)|>>|\^\^|[<>^])\??)' comment: End at anything that would end a parameter, including redirections *if* they are *not* this same type of redirection (ie, redirection into file descriptor, or into pipe), in which case this scope stays open and we match the next one push: - match: '(?=[\s;&)|]|(?:[0-9]+(?:<|>|>>)|>>|\^\^|[<>^])[&|])' pop: true - match: '(?:([0-9]+)(<|>|>>)|(>>|\^\^|[<>^]))(\?)?\s*' comment: We have to try and catch bad operators here because if they are seen by the outer end match then they will be considered valid and the redirection scope will immediately terminate captures: 1: constant.numeric.file-descriptor.fish 2: keyword.operator.redirect.fish 3: keyword.operator.redirect.fish 4: keyword.operator.redirect.clobber-test.fish push: - meta_scope: meta.redirection.fish - match: "((?:[&?]|[0-9]*[<>^]).*$)|(?![&?<>^])" captures: 1: invalid.illegal.path.fish pop: true - include: line-continuation - match: (?=\\\n) push: - meta_scope: meta.redirection.fish - match: (?!\\\n) pop: true - include: line-continuation - match: (?=\() comment: Evaluates to a string, so path cannot begin with '(' push: - meta_scope: meta.redirection.fish - match: (?!\() pop: true - include: command-substitution - match: (?=\$) comment: Evaluates to a string, so path cannot begin with '$' push: - meta_scope: meta.redirection.fish - match: (?!\$) pop: true - include: variable-expansion - match: "(?:[&?]|[0-9]*[<>^]).*$" comment: Check for characters which are associated with redirection, so path cannot begin with them. Don't put them in the meta.redirection.path scope, so that only valid paths are in there scope: meta.redirection.fish invalid.illegal.path.fish - match: \~ scope: meta.redirection.path.fish keyword.operator.tilde.fish - match: '(?:\''(?:\\[\''\\]|[^\''\\])*\''|\"(?:\\[\"$\n\\]|[^\"$\n\\])*\"|(?:\\[abefnrtv $\\*?#(){}\[\]<>^&;|"'']|\\[~%]|\\[xX][0-9A-Fa-f]{1,2}|\\[0-7]{1,3}|\\u[0-9A-Fa-f]{1,4}|\\U[0-9A-Fa-f]{1,8}|\\c[?-~]|[^\s$\\*?~%#()<>&|;"'']|\\(?=[^abefnrtv\s$\\*?#(){}\[\]<>^&;|"''xXuUc])|\\\n|[~%#])+)+' comment: Use the function call match to build a file path, as the syntax is fairly similar (possibly identical, after exceptions caught above? I haven't checked) scope: meta.redirection.path.fish string: - include: string-quoted - include: string-unquoted string-quoted: - match: \' captures: 0: punctuation.definition.string.begin.fish push: - meta_scope: string.quoted.single.fish - match: \' captures: 0: punctuation.definition.string.end.fish pop: true - match: '\\[\''\\]' comment: Only accepted escapes are \' and \\ scope: constant.character.escape.fish - match: \" captures: 0: punctuation.definition.string.begin.fish push: - meta_scope: string.quoted.double.fish - match: \" captures: 0: punctuation.definition.string.end.fish pop: true - match: '\\[\n\"\\$]' comment: Only accepted escapes are \, \", \\, and \$ scope: constant.character.escape.fish - include: variable-expansion string-unquoted: - match: '(?![\s;&()|<>''"$])' comment: End unquoted string at anything that can't be in one push: - meta_scope: meta.string.unquoted.fish - match: '(?=[\s;&()|<>''"$])' pop: true - include: string-unquoted-patterns string-unquoted-patterns: - match: |- (?x) \\[abefnrtv $\\*?#(){}\[\]<>^&|;"'] | \\[~%] | \\[xX][0-9A-Fa-f]{1,2} | \\[0-7]{1,3} | \\u[0-9A-Fa-f]{1,4} | \\U[0-9A-Fa-f]{1,8} | \\c[?-~] comment: This list follows the order given in official fish documentation. Technically '~' and '%' only need escaping if they appear at the front of a parameter. If they are escaped within a parameter, then fish does not *highlight* the escape, however it does silently *parse* the escape and the backslash is removed before the parameter is passed to the command. So, we highlight these escapes as well since they are actually treated as valid escapes by fish scope: constant.character.escape.fish - match: \\\n comment: Just for convenience we separate the newline escape scope: constant.character.escape.fish - match: '\{' captures: 0: punctuation.section.braces.begin.fish push: - meta_scope: meta.braces.brace-expansion.fish - match: '(\})|(\n|[;&)|].*)' captures: 1: punctuation.section.braces.end.fish 2: invalid.illegal.punctuation.section.fish pop: true - match: \, scope: punctuation.section.braces.separator.fish - include: command-substitution - include: variable-expansion - match: '(?:[^\S\n]+)' comment: Unescaped spaces aren't allowed, as technically that separates the braces into two separate arguments. Don't consume a newline though, so the scope end capture can get it scope: invalid.illegal.whitespace.fish - include: string-quoted - match: '(?:[+-]?)(?:[0-9]+\.?[0-9]*|[0-9]*\.?[0-9]+)(?=$|[\s;&)|<>]|\}|\,)' scope: constant.numeric.fish - match: '(?![\s;&)|<>''"])' comment: "Begin/end string as before with the addition of breaking at a '}' or ','" push: - match: '(?=[\s;&)|<>''"]|\}|\,)' pop: true - match: \\\, scope: constant.character.escape.fish - include: string-unquoted-patterns - match: (\*\*)|(\*)|(\?) scope: meta.wildcard-expansion.fish captures: 1: keyword.operator.double-star.fish 2: keyword.operator.single-star.fish 3: keyword.operator.question-mark.fish variable-expansion: - include: variable-expansion-illegal - match: (?=\$) comment: 'Capture "$foo" or "$foo[]" or "$$foo[][]" etc' push: - meta_scope: meta.variable-expansion.fish - match: '(?=[^\$\w\[])' pop: true - match: \$ captures: 0: punctuation.definition.variable.fish push: - meta_scope: variable.other.fish - match: '(?=[^\$\w])' pop: true - include: variable-expansion-illegal - include: variable-expansion-simple - include: index-expansion variable-expansion-illegal: - match: '\$(?:(?=[,''"\]}\s;&)|])|[^\w\$][^$,''"\]}\s;&)|]*)' comment: A lone '$' in a scope, or an attempt to expand a variable starting with a nonword character, is an error. These boundaries are the same as for meta.string.unquoted scope: invalid.illegal.variable-expansion.fish variable-expansion-simple: - match: \$ captures: 0: punctuation.definition.variable.fish push: - meta_scope: variable.other.fish - match: '(?=[^\$\w])' pop: true - include: variable-expansion-illegal - include: variable-expansion-simple