UNPKG

test

Version:

Node.js 18's node:test, as an npm package

991 lines (836 loc) 28.6 kB
// https://github.com/nodejs/node/blob/4c08c20e575a0954fe3977a20e9f52b4980a2e48/lib/internal/test_runner/tap_parser.js 'use strict' const { ArrayPrototypeFilter, ArrayPrototypeForEach, ArrayPrototypeIncludes, ArrayPrototypeJoin, ArrayPrototypeMap, ArrayPrototypePop, ArrayPrototypePush, Boolean, Number, RegExpPrototypeExec, String, StringPrototypeEndsWith, StringPrototypeReplaceAll, StringPrototypeSlice, StringPrototypeSplit, StringPrototypeTrim } = require('#internal/per_context/primordials') const Transform = require('stream').Transform const { TapLexer, TokenKind } = require('#internal/test_runner/tap_lexer') const { TapChecker } = require('#internal/test_runner/tap_checker') const { codes: { ERR_TAP_VALIDATION_ERROR, ERR_TAP_PARSER_ERROR } } = require('#internal/errors') const { kEmptyObject } = require('#internal/util') /** * * TAP14 specifications * * See https://testanything.org/tap-version-14-specification.html * * Note that the following grammar is intended as a rough "pseudocode" guidance. * It is not strict EBNF: * * TAPDocument := Version Plan Body | Version Body Plan * Version := "TAP version 14\n" * Plan := "1.." (Number) (" # " Reason)? "\n" * Body := (TestPoint | BailOut | Pragma | Comment | Anything | Empty | Subtest)* * TestPoint := ("not ")? "ok" (" " Number)? ((" -")? (" " Description) )? (" " Directive)? "\n" (YAMLBlock)? * Directive := " # " ("todo" | "skip") (" " Reason)? * YAMLBlock := " ---\n" (YAMLLine)* " ...\n" * YAMLLine := " " (YAML)* "\n" * BailOut := "Bail out!" (" " Reason)? "\n" * Reason := [^\n]+ * Pragma := "pragma " [+-] PragmaKey "\n" * PragmaKey := ([a-zA-Z0-9_-])+ * Subtest := ("# Subtest" (": " SubtestName)?)? "\n" SubtestDocument TestPoint * Comment := ^ (" ")* "#" [^\n]* "\n" * Empty := [\s\t]* "\n" * Anything := [^\n]+ "\n" * */ /** * An LL(1) parser for TAP14/TAP13. */ class TapParser extends Transform { #checker = null #lexer = null #currentToken = null #input = '' #currentChunkAsString = '' #lastLine = '' #tokens = [[]] #flatAST = [] #bufferedComments = [] #bufferedTestPoints = [] #lastTestPointDetails = {} #yamlBlockBuffer = [] #currentTokenIndex = 0 #currentTokenChunk = 0 #subTestNestingLevel = 0 #yamlCurrentIndentationLevel = 0 #kSubtestBlockIndentationFactor = 4 #isYAMLBlock = false #isSyncParsingEnabled = false constructor ({ specs = TapChecker.TAP13 } = kEmptyObject) { super({ __proto__: null, readableObjectMode: true }) this.#checker = new TapChecker({ specs }) } // ----------------------------------------------------------------------// // ----------------------------- Public API -----------------------------// // ----------------------------------------------------------------------// parse (chunkAsString = '', callback = null) { this.#isSyncParsingEnabled = false this.#currentTokenChunk = 0 this.#currentTokenIndex = 0 // Note: we are overwriting the input on each stream call // This is fine because we don't want to parse previous chunks this.#input = chunkAsString this.#lexer = new TapLexer(chunkAsString) try { this.#tokens = this.#scanTokens() this.#parseTokens(callback) } catch (error) { callback(null, error) } } parseSync (input = '', callback = null) { if (typeof input !== 'string' || input === '') { return [] } this.#isSyncParsingEnabled = true this.#input = input this.#lexer = new TapLexer(input) this.#tokens = this.#scanTokens() this.#parseTokens(callback) if (this.#isYAMLBlock) { // Looks like we have a non-ending YAML block this.#error('Expected end of YAML block') } // Manually flush the remaining buffered comments and test points this._flush() return this.#flatAST } // Check if the TAP content is semantically valid // Note: Validating the TAP content requires the whole AST to be available. check () { if (this.#isSyncParsingEnabled) { return this.#checker.check(this.#flatAST) } // TODO(@manekinekko): when running in async mode, it doesn't make sense to // validate the current chunk. Validation needs to whole AST to be available. throw new ERR_TAP_VALIDATION_ERROR( 'TAP validation is not supported for async parsing' ) } // ----------------------------------------------------------------------// // --------------------------- Transform API ----------------------------// // ----------------------------------------------------------------------// processChunk (chunk) { const str = this.#lastLine + chunk.toString('utf8') const lines = StringPrototypeSplit(str, '\n') this.#lastLine = ArrayPrototypePop(lines) let chunkAsString = ArrayPrototypeJoin(lines, '\n') // Special case where chunk is emitted by a child process chunkAsString = StringPrototypeReplaceAll( chunkAsString, '[out] ', '' ) chunkAsString = StringPrototypeReplaceAll( chunkAsString, '[err] ', '' ) if (StringPrototypeEndsWith(chunkAsString, '\n')) { chunkAsString = StringPrototypeSlice(chunkAsString, 0, -1) } if (StringPrototypeEndsWith(chunkAsString, 'EOF')) { chunkAsString = StringPrototypeSlice(chunkAsString, 0, -3) } return chunkAsString } _transform (chunk, _encoding, next) { const chunkAsString = this.processChunk(chunk) if (!chunkAsString) { // Ignore empty chunks next() return } this.parse(chunkAsString, (node, error) => { if (error) { next(error) return } if (node.kind === TokenKind.EOF) { // Emit when the current chunk is fully processed and consumed next() } }) } // Flush the remaining buffered comments and test points // This will be called automatically when the stream is closed // We also call this method manually when we reach the end of the sync parsing _flush (next = null) { if (!this.#lastLine) { this.#__flushPendingTestPointsAndComments() next?.() return } // Parse the remaining line this.parse(this.#lastLine, (node, error) => { this.#lastLine = '' if (error) { next?.(error) return } if (node.kind === TokenKind.EOF) { this.#__flushPendingTestPointsAndComments() next?.() } }) } #__flushPendingTestPointsAndComments () { ArrayPrototypeForEach(this.#bufferedTestPoints, (node) => { this.#emit(node) }) ArrayPrototypeForEach(this.#bufferedComments, (node) => { this.#emit(node) }) // Clean up this.#bufferedTestPoints = [] this.#bufferedComments = [] } // ----------------------------------------------------------------------// // ----------------------------- Private API ----------------------------// // ----------------------------------------------------------------------// #scanTokens () { return this.#lexer.scan() } #parseTokens (callback = null) { for (let index = 0; index < this.#tokens.length; index++) { const chunk = this.#tokens[index] this.#parseChunk(chunk) } callback?.({ kind: TokenKind.EOF }) // eslint-disable-line n/no-callback-literal } #parseChunk (chunk) { this.#subTestNestingLevel = this.#getCurrentIndentationLevel(chunk) // We compute the current index of the token in the chunk // based on the indentation level (number of spaces). // We also need to take into account if we are in a YAML block or not. // If we are in a YAML block, we compute the current index of the token // based on the indentation level of the YAML block (start block). if (this.#isYAMLBlock) { this.#currentTokenIndex = this.#yamlCurrentIndentationLevel * this.#kSubtestBlockIndentationFactor } else { this.#currentTokenIndex = this.#subTestNestingLevel * this.#kSubtestBlockIndentationFactor this.#yamlCurrentIndentationLevel = this.#subTestNestingLevel } let node // Parse current chunk try { node = this.#TAPDocument(chunk) } catch { node = { kind: TokenKind.UNKNOWN, node: { value: this.#currentChunkAsString } } } // Emit the parsed node to both the stream and the AST this.#emitOrBufferCurrentNode(node) // Move pointers to the next chunk and reset the current token index this.#currentTokenChunk++ this.#currentTokenIndex = 0 } #error (message) { const token = this.#currentToken || { value: '', kind: '' } // Escape NewLine characters if (token.value === '\n') { token.value = '\\n' } throw new ERR_TAP_PARSER_ERROR( message, `, received "${token.value}" (${token.kind})`, token, this.#input ) } #peek (shouldSkipBlankTokens = true) { if (shouldSkipBlankTokens) { this.#skip(TokenKind.WHITESPACE) } return this.#tokens[this.#currentTokenChunk][this.#currentTokenIndex] } #next (shouldSkipBlankTokens = true) { if (shouldSkipBlankTokens) { this.#skip(TokenKind.WHITESPACE) } if (this.#tokens[this.#currentTokenChunk]) { this.#currentToken = this.#tokens[this.#currentTokenChunk][this.#currentTokenIndex++] } else { this.#currentToken = null } return this.#currentToken } // Skip the provided tokens in the current chunk #skip (...tokensToSkip) { let token = this.#tokens[this.#currentTokenChunk][this.#currentTokenIndex] while (token && ArrayPrototypeIncludes(tokensToSkip, token.kind)) { // pre-increment to skip current tokens but make sure we don't advance index on the last iteration token = this.#tokens[this.#currentTokenChunk][++this.#currentTokenIndex] } } #readNextLiterals () { const literals = [] let nextToken = this.#peek(false) // Read all literal, numeric, whitespace and escape tokens until we hit a different token // or reach end of current chunk while ( nextToken && ArrayPrototypeIncludes( [ TokenKind.LITERAL, TokenKind.NUMERIC, TokenKind.DASH, TokenKind.PLUS, TokenKind.WHITESPACE, TokenKind.ESCAPE ], nextToken.kind ) ) { const word = this.#next(false).value // Don't output escaped characters if (nextToken.kind !== TokenKind.ESCAPE) { ArrayPrototypePush(literals, word) } nextToken = this.#peek(false) } return ArrayPrototypeJoin(literals, '') } #countLeadingSpacesInCurrentChunk (chunk) { // Count the number of whitespace tokens in the chunk, starting from the first token let whitespaceCount = 0 while (chunk?.[whitespaceCount]?.kind === TokenKind.WHITESPACE) { whitespaceCount++ } return whitespaceCount } #addDiagnosticsToLastTestPoint (currentNode) { const { length, [length - 1]: lastTestPoint } = this.#bufferedTestPoints // Diagnostic nodes are only added to Test points of the same nesting level if (lastTestPoint && lastTestPoint.nesting === currentNode.nesting) { lastTestPoint.node.time = this.#lastTestPointDetails.duration // TODO(@manekinekko): figure out where to put the other diagnostic properties // See https://github.com/nodejs/node/pull/44952 lastTestPoint.node.diagnostics = lastTestPoint.node.diagnostics || [] ArrayPrototypeForEach(currentNode.node.diagnostics, (diagnostic) => { // Avoid adding empty diagnostics if (diagnostic) { ArrayPrototypePush(lastTestPoint.node.diagnostics, diagnostic) } }) this.#bufferedTestPoints = [] } return lastTestPoint } #flushBufferedTestPointNode (shouldClearBuffer = true) { if (this.#bufferedTestPoints.length > 0) { this.#emit(this.#bufferedTestPoints[0]) if (shouldClearBuffer) { this.#bufferedTestPoints = [] } } } #addCommentsToCurrentNode (currentNode) { if (this.#bufferedComments.length > 0) { currentNode.comments = ArrayPrototypeMap( this.#bufferedComments, (c) => c.node.comment ) this.#bufferedComments = [] } return currentNode } #flushBufferedComments (shouldClearBuffer = true) { if (this.#bufferedComments.length > 0) { ArrayPrototypeForEach(this.#bufferedComments, (node) => { this.#emit(node) }) if (shouldClearBuffer) { this.#bufferedComments = [] } } } #getCurrentIndentationLevel (chunk) { const whitespaceCount = this.#countLeadingSpacesInCurrentChunk(chunk) return (whitespaceCount / this.#kSubtestBlockIndentationFactor) | 0 } #emit (node) { if (node.kind !== TokenKind.EOF) { ArrayPrototypePush(this.#flatAST, node) this.push({ __proto__: null, ...node }) } } #emitOrBufferCurrentNode (currentNode) { currentNode = { ...currentNode, nesting: this.#subTestNestingLevel, lexeme: this.#currentChunkAsString } switch (currentNode.kind) { // Emit these nodes case TokenKind.UNKNOWN: if (!currentNode.node.value) { // Ignore unrecognized and empty nodes break } // falls through case TokenKind.TAP_PLAN: case TokenKind.TAP_PRAGMA: case TokenKind.TAP_VERSION: case TokenKind.TAP_BAIL_OUT: case TokenKind.TAP_SUBTEST_POINT: // Check if we have a buffered test point, and if so, emit it this.#flushBufferedTestPointNode() // If we have buffered comments, add them to the current node currentNode = this.#addCommentsToCurrentNode(currentNode) // Emit the current node this.#emit(currentNode) break // By default, we buffer the next test point node in case we have a diagnostic // to add to it in the next iteration // Note: in case we hit and EOF, we flush the comments buffer (see _flush()) case TokenKind.TAP_TEST_POINT: // In case of an already buffered test point, we flush it and buffer the current one // Because diagnostic nodes are only added to the last processed test point this.#flushBufferedTestPointNode() // Buffer this node (and also add any pending comments to it) ArrayPrototypePush( this.#bufferedTestPoints, this.#addCommentsToCurrentNode(currentNode) ) break // Keep buffering comments until we hit a non-comment node, then add them to the that node // Note: in case we hit and EOF, we flush the comments buffer (see _flush()) case TokenKind.COMMENT: ArrayPrototypePush(this.#bufferedComments, currentNode) break // Diagnostic nodes are added to Test points of the same nesting level case TokenKind.TAP_YAML_END: // Emit either the last updated test point (w/ diagnostics) or the current diagnostics node alone this.#emit( this.#addDiagnosticsToLastTestPoint(currentNode) || currentNode ) break // In case we hit an EOF, we emit it to indicate the end of the stream case TokenKind.EOF: this.#emit(currentNode) break } } #serializeChunk (chunk) { return ArrayPrototypeJoin( ArrayPrototypeMap( // Exclude NewLine and EOF tokens ArrayPrototypeFilter( chunk, (token) => token.kind !== TokenKind.NEWLINE && token.kind !== TokenKind.EOF ), (token) => token.value ), '' ) } // --------------------------------------------------------------------------// // ------------------------------ Parser rules ------------------------------// // --------------------------------------------------------------------------// // TAPDocument := Version Plan Body | Version Body Plan #TAPDocument (tokenChunks) { this.#currentChunkAsString = this.#serializeChunk(tokenChunks) const firstToken = this.#peek(false) if (firstToken) { const { kind } = firstToken switch (kind) { case TokenKind.TAP: return this.#Version() case TokenKind.NUMERIC: return this.#Plan() case TokenKind.TAP_TEST_OK: case TokenKind.TAP_TEST_NOTOK: return this.#TestPoint() case TokenKind.COMMENT: case TokenKind.HASH: return this.#Comment() case TokenKind.TAP_PRAGMA: return this.#Pragma() case TokenKind.WHITESPACE: return this.#YAMLBlock() case TokenKind.LITERAL: // Check for "Bail out!" literal (case insensitive) if ( RegExpPrototypeExec(/^Bail\s+out!/i, this.#currentChunkAsString) ) { return this.#Bailout() } else if (this.#isYAMLBlock) { return this.#YAMLBlock() } // Read token because error needs the last token details this.#next(false) this.#error('Expected a valid token') break case TokenKind.EOF: return firstToken case TokenKind.NEWLINE: // Consume and ignore NewLine token return this.#next(false) default: // Read token because error needs the last token details this.#next(false) this.#error('Expected a valid token') } } const node = { kind: TokenKind.UNKNOWN, node: { value: this.#currentChunkAsString } } // We make sure the emitted node has the same shape // both in sync and async parsing (for the stream interface) return node } // ----------------Version---------------- // Version := "TAP version Number\n" #Version () { const tapToken = this.#peek() if (tapToken.kind === TokenKind.TAP) { this.#next() // Consume the TAP token } else { this.#error('Expected "TAP" keyword') } const versionToken = this.#peek() if (versionToken?.kind === TokenKind.TAP_VERSION) { this.#next() // Consume the version token } else { this.#error('Expected "version" keyword') } const numberToken = this.#peek() if (numberToken?.kind === TokenKind.NUMERIC) { const version = this.#next().value const node = { kind: TokenKind.TAP_VERSION, node: { version } } return node } this.#error('Expected a version number') } // ----------------Plan---------------- // Plan := "1.." (Number) (" # " Reason)? "\n" #Plan () { // Even if specs mention plan starts at 1, we need to make sure we read the plan start value // in case of a missing or invalid plan start value const planStart = this.#next() if (planStart.kind !== TokenKind.NUMERIC) { this.#error('Expected a plan start count') } const planToken = this.#next() if (planToken?.kind !== TokenKind.TAP_PLAN) { this.#error('Expected ".." symbol') } const planEnd = this.#next() if (planEnd?.kind !== TokenKind.NUMERIC) { this.#error('Expected a plan end count') } const plan = { start: planStart.value, end: planEnd.value } // Read optional reason const hashToken = this.#peek() if (hashToken) { if (hashToken.kind === TokenKind.HASH) { this.#next() // skip hash plan.reason = StringPrototypeTrim(this.#readNextLiterals()) } else if (hashToken.kind === TokenKind.LITERAL) { this.#error('Expected "#" symbol before a reason') } } const node = { kind: TokenKind.TAP_PLAN, node: plan } return node } // ----------------TestPoint---------------- // TestPoint := ("not ")? "ok" (" " Number)? ((" -")? (" " Description) )? (" " Directive)? "\n" (YAMLBlock)? // Directive := " # " ("todo" | "skip") (" " Reason)? // YAMLBlock := " ---\n" (YAMLLine)* " ...\n" // YAMLLine := " " (YAML)* "\n" // Test Status: ok/not ok (required) // Test number (recommended) // Description (recommended, prefixed by " - ") // Directive (only when necessary) #TestPoint () { const notToken = this.#peek() let isTestFailed = false if (notToken.kind === TokenKind.TAP_TEST_NOTOK) { this.#next() // skip "not" token isTestFailed = true } const okToken = this.#next() if (okToken.kind !== TokenKind.TAP_TEST_OK) { this.#error('Expected "ok" or "not ok" keyword') } // Read optional test number let numberToken = this.#peek() if (numberToken && numberToken.kind === TokenKind.NUMERIC) { numberToken = this.#next().value } else { numberToken = '' // Set an empty ID to indicate that the test hasn't provider an ID } const test = { // Output both failed and passed properties to make it easier for the checker to detect the test status status: { fail: isTestFailed, pass: !isTestFailed, todo: false, skip: false }, id: numberToken, description: '', reason: '', time: 0, diagnostics: [] } // Read optional description prefix " - " const descriptionDashToken = this.#peek() if (descriptionDashToken && descriptionDashToken.kind === TokenKind.DASH) { this.#next() // skip dash } // Read optional description if (this.#peek()) { const description = StringPrototypeTrim(this.#readNextLiterals()) if (description) { test.description = description } } // Read optional directive and reason const hashToken = this.#peek() if (hashToken && hashToken.kind === TokenKind.HASH) { this.#next() // skip hash } let todoOrSkipToken = this.#peek() if (todoOrSkipToken && todoOrSkipToken.kind === TokenKind.LITERAL) { if (RegExpPrototypeExec(/todo/i, todoOrSkipToken.value)) { todoOrSkipToken = 'todo' this.#next() // skip token } else if (RegExpPrototypeExec(/skip/i, todoOrSkipToken.value)) { todoOrSkipToken = 'skip' this.#next() // skip token } } const reason = StringPrototypeTrim(this.#readNextLiterals()) if (todoOrSkipToken) { if (reason) { test.reason = reason } test.status.todo = todoOrSkipToken === 'todo' test.status.skip = todoOrSkipToken === 'skip' } const node = { kind: TokenKind.TAP_TEST_POINT, node: test } return node } // ----------------Bailout---------------- // BailOut := "Bail out!" (" " Reason)? "\n" #Bailout () { this.#next() // skip "Bail" this.#next() // skip "out!" // Read optional reason const hashToken = this.#peek() if (hashToken && hashToken.kind === TokenKind.HASH) { this.#next() // skip hash } const reason = StringPrototypeTrim(this.#readNextLiterals()) const node = { kind: TokenKind.TAP_BAIL_OUT, node: { bailout: true, reason } } return node } // ----------------Comment---------------- // Comment := ^ (" ")* "#" [^\n]* "\n" #Comment () { const commentToken = this.#next() if ( commentToken.kind !== TokenKind.COMMENT && commentToken.kind !== TokenKind.HASH ) { this.#error('Expected "#" symbol') } const commentContent = this.#peek() if (commentContent) { if (RegExpPrototypeExec(/^Subtest:/i, commentContent.value) !== null) { this.#next() // skip subtest keyword const name = StringPrototypeTrim(this.#readNextLiterals()) const node = { kind: TokenKind.TAP_SUBTEST_POINT, node: { name } } return node } const comment = StringPrototypeTrim(this.#readNextLiterals()) const node = { kind: TokenKind.COMMENT, node: { comment } } return node } // If there is no comment content, then we ignore the current node } // ----------------YAMLBlock---------------- // YAMLBlock := " ---\n" (YAMLLine)* " ...\n" #YAMLBlock () { const space1 = this.#peek(false) if (space1 && space1.kind === TokenKind.WHITESPACE) { this.#next(false) // skip 1st space } const space2 = this.#peek(false) if (space2 && space2.kind === TokenKind.WHITESPACE) { this.#next(false) // skip 2nd space } const yamlBlockSymbol = this.#peek(false) if (yamlBlockSymbol.kind === TokenKind.WHITESPACE) { if (this.#isYAMLBlock === false) { this.#next(false) // skip 3rd space this.#error('Expected valid YAML indentation (2 spaces)') } } if (yamlBlockSymbol.kind === TokenKind.TAP_YAML_START) { if (this.#isYAMLBlock) { // Looks like we have another YAML start block, but we didn't close the previous one this.#error('Unexpected YAML start marker') } this.#isYAMLBlock = true this.#yamlCurrentIndentationLevel = this.#subTestNestingLevel this.#lastTestPointDetails = {} // Consume the YAML start marker this.#next(false) // skip "---" // No need to pass this token to the stream interface return } else if (yamlBlockSymbol.kind === TokenKind.TAP_YAML_END) { this.#next(false) // skip "..." if (!this.#isYAMLBlock) { // Looks like we have an YAML end block, but we didn't encounter any YAML start marker this.#error('Unexpected YAML end marker') } this.#isYAMLBlock = false const diagnostics = this.#yamlBlockBuffer this.#yamlBlockBuffer = [] // Free the buffer for the next YAML block const node = { kind: TokenKind.TAP_YAML_END, node: { diagnostics } } return node } if (this.#isYAMLBlock) { this.#YAMLLine() } else { return { kind: TokenKind.UNKNOWN, node: { value: yamlBlockSymbol.value } } } } // ----------------YAMLLine---------------- // YAMLLine := " " (YAML)* "\n" #YAMLLine () { const yamlLiteral = this.#readNextLiterals() const { 0: key, 1: value } = StringPrototypeSplit(yamlLiteral, ':', 2) // Note that this.#lastTestPointDetails has been cleared when we encounter a YAML start marker switch (key) { case 'duration_ms': this.#lastTestPointDetails.duration = Number(value) break // Below are diagnostic properties introduced in https://github.com/nodejs/node/pull/44952 case 'expected': this.#lastTestPointDetails.expected = Boolean(value) break case 'actual': this.#lastTestPointDetails.actual = Boolean(value) break case 'operator': this.#lastTestPointDetails.operator = String(value) break } ArrayPrototypePush(this.#yamlBlockBuffer, yamlLiteral) } // ----------------PRAGMA---------------- // Pragma := "pragma " [+-] PragmaKey "\n" // PragmaKey := ([a-zA-Z0-9_-])+ // TODO(@manekinekko): pragmas are parsed but not used yet! TapChecker() should take care of that. #Pragma () { const pragmaToken = this.#next() if (pragmaToken.kind !== TokenKind.TAP_PRAGMA) { this.#error('Expected "pragma" keyword') } const pragmas = {} let nextToken = this.#peek() while ( nextToken && ArrayPrototypeIncludes( [TokenKind.NEWLINE, TokenKind.EOF, TokenKind.EOL], nextToken.kind ) === false ) { let isEnabled = true const pragmaKeySign = this.#next() if (pragmaKeySign.kind === TokenKind.PLUS) { isEnabled = true } else if (pragmaKeySign.kind === TokenKind.DASH) { isEnabled = false } else { this.#error('Expected "+" or "-" before pragma keys') } const pragmaKeyToken = this.#peek() if (pragmaKeyToken.kind !== TokenKind.LITERAL) { this.#error('Expected pragma key') } let pragmaKey = this.#next().value // In some cases, pragma key can be followed by a comma separator, // so we need to remove it pragmaKey = StringPrototypeReplaceAll(pragmaKey, ',', '') pragmas[pragmaKey] = isEnabled nextToken = this.#peek() } const node = { kind: TokenKind.TAP_PRAGMA, node: { pragmas } } return node } } module.exports = { TapParser }