import {ExternalTokenizer, InputStream, Stack} from "@lezer/lr" import { If, Then, Else, Endif, While, Foreach, End, Switch, Case, Default, Endsw, Repeat, Set, Setenv, Unset, Alias, At, GroupOpen, GroupClose, Label, Terminator, insertedTerminator, blankLine, HeredocBody, Comment, wordEnd, LineContinuation, AssignOp, IncDecOp, EqualOp, MatchOp, RelateOp, ShiftOp, AddOp, MulOp, BitAndOp, BitXorOp, BitOrOp, NotOp, BitNotOp, FileTestOp, AndOp, OrOp, Number } from "./csh-parser.terms.js" const space = 32, tab = 9, carriage = 13, lineFeed = 10, hash = 35, semi = 59, amp = 38, pipe = 124, lt = 60, gt = 62, parenL = 40, parenR = 41, braceL = 123, braceR = 125, colon = 58, at = 64, quote = 34, apostrophe = 39, backslash = 92, dash = 45, plus = 43 function isBlank(ch: number) { return ch == space || ch == tab || ch == carriage } // The characters that end a word. `:` is one of them here and is not in the // shell command language, because csh hangs its modifiers off a word with one: // `$path:h` is two things and `$path` is one. function endsWord(ch: number) { return ( ch < 0 || isBlank(ch) || ch == lineFeed || ch == semi || ch == amp || ch == pipe || ch == parenL || 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) => { // A backslash before a newline is a piece of the word it touches, and space // where it touches none. if ( input.next == backslash && input.peek(1) == lineFeed && !isBlank(input.peek(-1)) && stack.canShift(LineContinuation) ) { input.acceptToken(LineContinuation, 2) return } if (isIncrement(input) && stack.canShift(wordEnd) && namedByAt(input)) { input.acceptToken(wordEnd) return } if (endsWord(input.next) && stack.canShift(wordEnd)) input.acceptToken(wordEnd) }, {contextual: true}) function isIncrement(input: InputStream) { return ( (input.next == plus || input.next == dash) && input.peek(1) == input.next && endsWord(input.peek(2)) ) } // `@ n++` ends its word before the `++`, and `echo a++` does not. Which is // meant cannot be asked of the parser here: the word the increment follows has // not been reduced yet, so there is nowhere on the stack for the operator to go // even where one belongs. So it is asked of the text, which says the same // thing: the word an increment ends is the name an `@` opened. function namedByAt(input: InputStream) { let back = -1 while (-back < wordScanLimit && !endsWord(input.peek(back))) back-- while (isBlank(input.peek(back))) back-- return input.peek(back) == at && endsWord(input.peek(back - 1)) } const wordScanLimit = 256 // ── Reserved words ──────────────────────────────────────────────────────────── const keywords: {[name: string]: number} = { if: If, then: Then, else: Else, endif: Endif, while: While, foreach: Foreach, end: End, switch: Switch, case: Case, default: Default, endsw: Endsw, repeat: Repeat, set: Set, setenv: Setenv, unset: Unset, alias: Alias } // 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 loop. The // question is the parser's, because a command cannot start until the one // before it has been terminated. // // `set` and `alias` are here with the rest of them. They are builtins rather // than reserved words in the shell's own reckoning, but what follows each is a // name being defined rather than an argument, and saying so is most of what a // reader wants from this grammar. export const reservedWords = new ExternalTokenizer((input: InputStream, stack: Stack) => { const ch = input.next if (ch == at) return takeIf(input, stack, At, 1, blankFollows(input, 1)) if (ch == braceL) return takeIf(input, stack, GroupOpen, 1, blankFollows(input, 1)) if (ch == braceR) return takeIf(input, stack, GroupClose, 1, endsWord(input.peek(1))) if (!isNameStart(ch)) return let length = 0, word = "" while (isNameChar(input.peek(length))) word += String.fromCharCode(input.peek(length++)) const term = keywords[word] if (term == null) return // `default:` carries its colon, because a colon is an ordinary letter in a // csh word and leaving it out would put a stray one after the keyword. The // label of a `case` keeps its own for the same reason. if (term == Default && input.peek(length) == colon) length++ else if (!endsWord(input.peek(length))) return takeIf(input, stack, term, length, true) }, {contextual: true}) function blankFollows(input: InputStream, from: number) { const ch = input.peek(from) return ch < 0 || isBlank(ch) || ch == lineFeed } function takeIf(input: InputStream, stack: Stack, term: number, length: number, ok: boolean) { if (!ok || !stack.canShift(term)) return input.advance(length) input.acceptToken(term) } // ── The expression operators ────────────────────────────────────────────────── // Nearly every operator csh has is also an ordinary letter in a word. `-n` is // a flag and `-e` a file test, `*` is a wildcard and multiplication, `!` is a // history reference and negation, and `|` and `&` are the two ways a command // list is punctuated. Which one is meant is the question the rest of this file // asks: an expression is the only place the parser has room for an operator, // so the operator is produced there and nowhere else. // // Longest first, so that `<<=` is not read as `<<` and then `=`. const operators: [string, () => number][] = [ ["<<=", () => AssignOp], [">>=", () => AssignOp], ["==", () => EqualOp], ["!=", () => EqualOp], ["=~", () => MatchOp], ["!~", () => MatchOp], ["<=", () => RelateOp], [">=", () => RelateOp], ["<<", () => ShiftOp], [">>", () => ShiftOp], ["&&", () => AndOp], ["||", () => OrOp], ["++", () => IncDecOp], ["--", () => IncDecOp], ["+=", () => AssignOp], ["-=", () => AssignOp], ["*=", () => AssignOp], ["/=", () => AssignOp], ["%=", () => AssignOp], ["&=", () => AssignOp], ["^=", () => AssignOp], ["|=", () => AssignOp], ["<", () => RelateOp], [">", () => RelateOp], ["+", () => AddOp], ["-", () => AddOp], ["*", () => MulOp], ["/", () => MulOp], ["%", () => MulOp], ["&", () => BitAndOp], ["^", () => BitXorOp], ["|", () => BitOrOp], ["!", () => NotOp], ["~", () => BitNotOp], ["=", () => AssignOp] ] // `-e`, `-r`, `-w` and the rest ask something about a file, and each is a // letter that a flag could just as well have been. const fileTests = "erwxfdzo" export const expressionOps = new ExternalTokenizer((input: InputStream, stack: Stack) => { if ( input.next == dash && fileTests.indexOf(String.fromCharCode(input.peek(1))) > -1 && blankFollows(input, 2) && stack.canShift(FileTestOp) ) { input.acceptToken(FileTestOp, 2) return } for (const [text, term] of operators) { let i = 0 while (i < text.length && input.peek(i) == text.charCodeAt(i)) i++ if (i < text.length) continue const id = term() if (!stack.canShift(id)) continue input.acceptToken(id, text.length) return } // A number is only a number in an expression. `echo 1` passes a word, and // csh has no numbers anywhere else. if (input.next >= 48 && input.next <= 57 && stack.canShift(Number)) { let length = 0 while (input.peek(length) >= 48 && input.peek(length) <= 57) length++ if (endsWord(input.peek(length)) || !isNameChar(input.peek(length))) input.acceptToken(Number, length) } }, {contextual: true}) // ── Labels ──────────────────────────────────────────────────────────────────── // `goto done` jumps to a line reading `done:`, which is a name and a colon and // nothing else. A colon after a word means a modifier everywhere else, so this // asks for the rest of the line to be empty before calling it a label. export const labels = new ExternalTokenizer((input: InputStream, stack: Stack) => { if (!isNameStart(input.next) || !stack.canShift(Label)) return let length = 0 while (isNameChar(input.peek(length))) length++ if (input.peek(length) != colon) return let at = length + 1 while (isBlank(input.peek(at))) at++ const ch = input.peek(at) if (ch >= 0 && ch != lineFeed && ch != hash) return input.acceptToken(Label, length + 1) }, {contextual: true}) // ── Newlines ────────────────────────────────────────────────────────────────── // A newline ends a command where the parser has room for one and is skipped // everywhere else, which is every place csh allows a line to be broken: after // `|`, `&&`, or inside a parenthesised list. 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 || input.next == braceR) { 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 // A line that promised a heredoc has its body on the next line, so the // run stops here and the heredoc tokenizer reads what follows. if (heredocBefore(input, length - 1)) break } 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) }) // ── Heredocs ────────────────────────────────────────────────────────────────── // The body of a heredoc starts on the line after the `<<` that promised it, // which is nowhere near the token that promised it. Rather than carry the // promise from token to token, it is recovered here by reading back over the // line that just ended. csh allows one per line, where the shell command // language allows several. export const heredocs = new ExternalTokenizer((input: InputStream) => { if (input.peek(-1) != lineFeed) return const delimiter = heredocBefore(input, -1) if (!delimiter) return const length = skipBody(input, delimiter) if (length > 0) input.acceptToken(HeredocBody, length) }) // Reads the line that ends at `end` and returns the delimiter it promised. function heredocBefore(input: InputStream, end: number): string | null { let start = end while (input.peek(start - 1) >= 0 && input.peek(start - 1) != lineFeed) start-- // Cheap way out: a line with no `<` on it promised nothing, and nearly every // line is one of those. let seen = false for (let at = start; at < end && !seen; at++) if (input.peek(at) == lt) seen = true return seen ? scanLine(input, start, end) : null } function scanLine(input: InputStream, start: number, end: number): string | null { let at = start while (at < end) { const ch = input.peek(at) if (ch == backslash) { at += 2 } else if (ch == apostrophe || ch == quote) { at = skipQuoted(input, at, end, ch) } else if (ch == hash && (at == start || endsWord(input.peek(at - 1)))) { return null } else if (ch == lt && input.peek(at + 1) == lt) { at += 2 while (at < end && isBlank(input.peek(at))) at++ return readDelimiter(input, at, end) } else { at++ } } return null } function skipQuoted(input: InputStream, at: number, end: number, quoteChar: number) { at++ while (at < end) { const ch = input.peek(at) if (ch == backslash && quoteChar == quote) at += 2 else if (ch == quoteChar) return at + 1 else at++ } return at } // `<= 0 && input.peek(at) != lineFeed) at++ if (matchesDelimiter(input, lineStart, at, delimiter)) return input.peek(at) < 0 ? at : at + 1 if (input.peek(at) < 0) return at at++ } } function matchesDelimiter(input: InputStream, start: number, end: number, delimiter: string) { if (end - start != delimiter.length) return false for (let i = 0; i < delimiter.length; i++) if (input.peek(start + i) != delimiter.charCodeAt(i)) return false return true }