mirror of
https://github.com/nodejs/node.git
synced 2025-05-02 22:16:31 +00:00

PR-URL: https://github.com/nodejs/node/pull/46629 Reviewed-By: Jacob Smith <jacob@frende.me> Reviewed-By: Geoffrey Booth <webadmin@geoffreybooth.com> Reviewed-By: Chengzhong Wu <legendecas@gmail.com> Reviewed-By: Mohammed Keyvanzadeh <mohammadkeyvanzade94@gmail.com>
990 lines
28 KiB
JavaScript
990 lines
28 KiB
JavaScript
'use strict';
|
|
|
|
const {
|
|
ArrayPrototypeFilter,
|
|
ArrayPrototypeForEach,
|
|
ArrayPrototypeIncludes,
|
|
ArrayPrototypeJoin,
|
|
ArrayPrototypeMap,
|
|
ArrayPrototypePop,
|
|
ArrayPrototypePush,
|
|
Boolean,
|
|
Number,
|
|
RegExpPrototypeExec,
|
|
String,
|
|
StringPrototypeEndsWith,
|
|
StringPrototypeReplaceAll,
|
|
StringPrototypeSlice,
|
|
StringPrototypeSplit,
|
|
StringPrototypeTrim,
|
|
} = primordials;
|
|
const Transform = require('internal/streams/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 });
|
|
}
|
|
|
|
#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 ||= [];
|
|
|
|
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 };
|