// The tokenizers fish's grammar cannot express. // // Each of them answers a question about where a word begins and ends, and each // answers it by asking the parser what it has room for here. `fish.grammar` // says which question is which and why it has to be asked this way. // // The file is self-contained, as the other two tokenizers in this package are. // Three shells that share a word layer would share a helper module too, and // then a change made for one would have to be checked against the other two. // A reader who wants fish should be able to read fish. import {ExternalTokenizer, InputStream, Stack} from "@lezer/lr" import { If, Else, End, While, For, In, Switch, Case, Function, Begin, And, Or, Not, Terminator, insertedTerminator, blankLine, Comment, wordEnd, SubscriptOpen, SubscriptClose, bracketText } from "./fish-parser.terms.js" const space = 32, tab = 9, carriage = 13, lineFeed = 10, hash = 35, semi = 59, amp = 38, pipe = 124, lt = 60, gt = 62, parenR = 41, bracketL = 91, bracketR = 93 function isBlank(ch: number) { return ch == space || ch == tab || ch == carriage } // The characters that end a word. A `(` is not one of them: it opens a command // substitution, and `a(seq 3)b` is a single word in three pieces. function endsWord(ch: number) { return ( ch < 0 || isBlank(ch) || ch == lineFeed || ch == semi || ch == amp || ch == pipe || ch == parenR || ch == lt || ch == gt ) } function isNameStart(ch: number) { return (ch >= 65 && ch <= 90) || (ch >= 97 && ch <= 122) || ch == 95 } function isNameChar(ch: number) { return isNameStart(ch) || (ch >= 48 && ch <= 57) } // ── Words ───────────────────────────────────────────────────────────────────── // The pieces of a word are parsed with nothing skipped between them, which // leaves the grammar unable to see where the word stops. This token has no // width and says exactly that: the shell would end the word here. export const words = new ExternalTokenizer((input: InputStream, stack: Stack) => { if (endsWord(input.next) && stack.canShift(wordEnd)) input.acceptToken(wordEnd) }, {contextual: true}) // ── Reserved words ──────────────────────────────────────────────────────────── const keywords: {[name: string]: number} = { if: If, else: Else, end: End, while: While, for: For, in: In, switch: Switch, case: Case, function: Function, begin: Begin, and: And, or: Or, not: Not } // A reserved word is reserved where a command may start and nowhere else, so // `echo end` prints a word and the `end` on the next line closes the block. The // question is the parser's, because a command cannot start until the one before // it has been terminated. // // `in` is here with the rest of them, though it can only ever follow a `for`. // The parser knows that, and asking it is the same code as asking about `end`. export const reservedWords = new ExternalTokenizer((input: InputStream, stack: Stack) => { if (!isNameStart(input.next)) return let length = 0, word = "" while (isNameChar(input.peek(length))) word += String.fromCharCode(input.peek(length++)) const term = keywords[word] if (term == null || !endsWord(input.peek(length)) || !stack.canShift(term)) return input.advance(length) input.acceptToken(term) }, {contextual: true}) // ── Subscripts ──────────────────────────────────────────────────────────────── // `$argv[1]` indexes a list and `[ -f $file ]` runs the test command, and the // only difference between the two brackets is that the first one touches the // word in front of it. Both readings would otherwise be open at once, so the // choice is made here and the parser is handed one token or the other, never // both. export const brackets = new ExternalTokenizer((input: InputStream, stack: Stack) => { if (input.next == bracketL) { if (touchesWord(input.peek(-1)) && stack.canShift(SubscriptOpen)) input.acceptToken(SubscriptOpen, 1) else if (stack.canShift(bracketText)) input.acceptToken(bracketText, 1) return } if (input.next != bracketR) return if (stack.canShift(SubscriptClose)) input.acceptToken(SubscriptClose, 1) else if (stack.canShift(bracketText)) input.acceptToken(bracketText, 1) }, {contextual: true}) // A closing parenthesis ends a word everywhere else, and not here: `(seq 10)[-1]` // indexes what the command printed, and is the one thing a subscript may follow // that a word may not run into. function touchesWord(ch: number) { return ch == parenR || !endsWord(ch) } // ── Newlines ────────────────────────────────────────────────────────────────── // A newline ends a command where the parser has room for one and is skipped // everywhere else, which is every place fish allows a line to be broken: after // a `|`, an `and`, an `or`, or inside a command substitution. export const terminators = new ExternalTokenizer((input: InputStream, stack: Stack) => { // The last command in a file or a substitution needs no terminator of its // own, so one is supplied where the text runs out. It has its own width, // which is none. if (input.next < 0 || input.next == parenR) { if (stack.canShift(insertedTerminator)) input.acceptToken(insertedTerminator) return } if (input.next != lineFeed && input.next != semi) return let length = 0, newlines = false for (;;) { const ch = input.peek(length) if (ch == lineFeed) { length++ newlines = true } else if (ch == semi || isBlank(ch)) { length++ } else { break } } if (length == 0) return if (stack.canShift(Terminator)) input.acceptToken(Terminator, length) // Nothing to end, so a run of newlines here is a blank line between two // commands or a line broken inside one. Either way it is space. else if (newlines) input.acceptToken(blankLine, length) }, {contextual: true}) // ── Comments ────────────────────────────────────────────────────────────────── // `#` to the end of the line, but only where a word could start, so that `a#b` // is one word and `a #b` is a word and a comment. export const comments = new ExternalTokenizer((input: InputStream) => { if (input.next != hash || !endsWord(input.peek(-1))) return while (input.next != lineFeed && input.next >= 0) input.advance() input.acceptToken(Comment) })