UNPKG

@microsoft/tsdoc

Version:

A parser for the TypeScript doc comment syntax

863 lines (862 loc) 91.7 kB
"use strict"; // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. Object.defineProperty(exports, "__esModule", { value: true }); exports.NodeParser = void 0; const Token_1 = require("./Token"); const Tokenizer_1 = require("./Tokenizer"); const nodes_1 = require("../nodes"); const TokenSequence_1 = require("./TokenSequence"); const TokenReader_1 = require("./TokenReader"); const StringChecks_1 = require("./StringChecks"); const TSDocTagDefinition_1 = require("../configuration/TSDocTagDefinition"); const StandardTags_1 = require("../details/StandardTags"); const PlainTextEmitter_1 = require("../emitters/PlainTextEmitter"); const TSDocMessageId_1 = require("./TSDocMessageId"); function isFailure(resultOrFailure) { return resultOrFailure !== undefined && Object.hasOwnProperty.call(resultOrFailure, 'failureMessage'); } /** * The main parser for TSDoc comments. */ class NodeParser { constructor(parserContext) { this._parserContext = parserContext; this._configuration = parserContext.configuration; this._currentSection = parserContext.docComment.summarySection; } parse() { const tokenReader = new TokenReader_1.TokenReader(this._parserContext); let done = false; while (!done) { // Extract the next token switch (tokenReader.peekTokenKind()) { case Token_1.TokenKind.EndOfInput: done = true; break; case Token_1.TokenKind.Newline: this._pushAccumulatedPlainText(tokenReader); tokenReader.readToken(); this._pushNode(new nodes_1.DocSoftBreak({ parsed: true, configuration: this._configuration, softBreakExcerpt: tokenReader.extractAccumulatedSequence() })); break; case Token_1.TokenKind.Backslash: this._pushAccumulatedPlainText(tokenReader); this._pushNode(this._parseBackslashEscape(tokenReader)); break; case Token_1.TokenKind.AtSign: this._pushAccumulatedPlainText(tokenReader); this._parseAndPushBlock(tokenReader); break; case Token_1.TokenKind.LeftCurlyBracket: { this._pushAccumulatedPlainText(tokenReader); const marker = tokenReader.createMarker(); const docNode = this._parseInlineTag(tokenReader); const docComment = this._parserContext.docComment; if (docNode instanceof nodes_1.DocInheritDocTag) { // The @inheritDoc tag is irregular because it looks like an inline tag, but // it actually represents the entire comment body const tagEndMarker = tokenReader.createMarker() - 1; if (docComment.inheritDocTag === undefined) { this._parserContext.docComment.inheritDocTag = docNode; } else { this._pushNode(this._backtrackAndCreateErrorRange(tokenReader, marker, tagEndMarker, TSDocMessageId_1.TSDocMessageId.ExtraInheritDocTag, 'A doc comment cannot have more than one @inheritDoc tag')); } } else { this._pushNode(docNode); } break; } case Token_1.TokenKind.RightCurlyBracket: this._pushAccumulatedPlainText(tokenReader); this._pushNode(this._createError(tokenReader, TSDocMessageId_1.TSDocMessageId.EscapeRightBrace, 'The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag')); break; case Token_1.TokenKind.LessThan: this._pushAccumulatedPlainText(tokenReader); // Look ahead two tokens to see if this is "<a>" or "</a>". if (tokenReader.peekTokenAfterKind() === Token_1.TokenKind.Slash) { this._pushNode(this._parseHtmlEndTag(tokenReader)); } else { this._pushNode(this._parseHtmlStartTag(tokenReader)); } break; case Token_1.TokenKind.GreaterThan: this._pushAccumulatedPlainText(tokenReader); this._pushNode(this._createError(tokenReader, TSDocMessageId_1.TSDocMessageId.EscapeGreaterThan, 'The ">" character should be escaped using a backslash to avoid confusion with an HTML tag')); break; case Token_1.TokenKind.Backtick: this._pushAccumulatedPlainText(tokenReader); if (tokenReader.peekTokenAfterKind() === Token_1.TokenKind.Backtick && tokenReader.peekTokenAfterAfterKind() === Token_1.TokenKind.Backtick) { this._pushNode(this._parseFencedCode(tokenReader)); } else { this._pushNode(this._parseCodeSpan(tokenReader)); } break; default: // If nobody recognized this token, then accumulate plain text tokenReader.readToken(); break; } } this._pushAccumulatedPlainText(tokenReader); this._performValidationChecks(); } _performValidationChecks() { const docComment = this._parserContext.docComment; if (docComment.deprecatedBlock) { if (!PlainTextEmitter_1.PlainTextEmitter.hasAnyTextContent(docComment.deprecatedBlock)) { this._parserContext.log.addMessageForTokenSequence(TSDocMessageId_1.TSDocMessageId.MissingDeprecationMessage, `The ${docComment.deprecatedBlock.blockTag.tagName} block must include a deprecation message,` + ` e.g. describing the recommended alternative`, docComment.deprecatedBlock.blockTag.getTokenSequence(), docComment.deprecatedBlock); } } if (docComment.inheritDocTag) { if (docComment.remarksBlock) { this._parserContext.log.addMessageForTokenSequence(TSDocMessageId_1.TSDocMessageId.InheritDocIncompatibleTag, `A "${docComment.remarksBlock.blockTag.tagName}" block must not be used, because that` + ` content is provided by the @inheritDoc tag`, docComment.remarksBlock.blockTag.getTokenSequence(), docComment.remarksBlock.blockTag); } if (PlainTextEmitter_1.PlainTextEmitter.hasAnyTextContent(docComment.summarySection)) { this._parserContext.log.addMessageForTextRange(TSDocMessageId_1.TSDocMessageId.InheritDocIncompatibleSummary, 'The summary section must not have any content, because that' + ' content is provided by the @inheritDoc tag', this._parserContext.commentRange); } } } _validateTagDefinition(tagDefinition, tagName, expectingInlineTag, tokenSequenceForErrorContext, nodeForErrorContext) { if (tagDefinition) { const isInlineTag = tagDefinition.syntaxKind === TSDocTagDefinition_1.TSDocTagSyntaxKind.InlineTag; if (isInlineTag !== expectingInlineTag) { // The tag is defined, but it is used incorrectly if (expectingInlineTag) { this._parserContext.log.addMessageForTokenSequence(TSDocMessageId_1.TSDocMessageId.TagShouldNotHaveBraces, `The TSDoc tag "${tagName}" is not an inline tag; it must not be enclosed in "{ }" braces`, tokenSequenceForErrorContext, nodeForErrorContext); } else { this._parserContext.log.addMessageForTokenSequence(TSDocMessageId_1.TSDocMessageId.InlineTagMissingBraces, `The TSDoc tag "${tagName}" is an inline tag; it must be enclosed in "{ }" braces`, tokenSequenceForErrorContext, nodeForErrorContext); } } else { if (this._parserContext.configuration.validation.reportUnsupportedTags) { if (!this._parserContext.configuration.isTagSupported(tagDefinition)) { // The tag is defined, but not supported this._parserContext.log.addMessageForTokenSequence(TSDocMessageId_1.TSDocMessageId.UnsupportedTag, `The TSDoc tag "${tagName}" is not supported by this tool`, tokenSequenceForErrorContext, nodeForErrorContext); } } } } else { // The tag is not defined if (!this._parserContext.configuration.validation.ignoreUndefinedTags) { this._parserContext.log.addMessageForTokenSequence(TSDocMessageId_1.TSDocMessageId.UndefinedTag, `The TSDoc tag "${tagName}" is not defined in this configuration`, tokenSequenceForErrorContext, nodeForErrorContext); } } } _pushAccumulatedPlainText(tokenReader) { if (!tokenReader.isAccumulatedSequenceEmpty()) { this._pushNode(new nodes_1.DocPlainText({ parsed: true, configuration: this._configuration, textExcerpt: tokenReader.extractAccumulatedSequence() })); } } _parseAndPushBlock(tokenReader) { const docComment = this._parserContext.docComment; const configuration = this._parserContext.configuration; const modifierTagSet = docComment.modifierTagSet; const parsedBlockTag = this._parseBlockTag(tokenReader); if (parsedBlockTag.kind !== nodes_1.DocNodeKind.BlockTag) { this._pushNode(parsedBlockTag); return; } const docBlockTag = parsedBlockTag; // Do we have a definition for this tag? const tagDefinition = configuration.tryGetTagDefinitionWithUpperCase(docBlockTag.tagNameWithUpperCase); this._validateTagDefinition(tagDefinition, docBlockTag.tagName, /* expectingInlineTag */ false, docBlockTag.getTokenSequence(), docBlockTag); if (tagDefinition) { switch (tagDefinition.syntaxKind) { case TSDocTagDefinition_1.TSDocTagSyntaxKind.BlockTag: if (docBlockTag.tagNameWithUpperCase === StandardTags_1.StandardTags.param.tagNameWithUpperCase) { const docParamBlock = this._parseParamBlock(tokenReader, docBlockTag, StandardTags_1.StandardTags.param.tagName); this._parserContext.docComment.params.add(docParamBlock); this._currentSection = docParamBlock.content; return; } else if (docBlockTag.tagNameWithUpperCase === StandardTags_1.StandardTags.typeParam.tagNameWithUpperCase) { const docParamBlock = this._parseParamBlock(tokenReader, docBlockTag, StandardTags_1.StandardTags.typeParam.tagName); this._parserContext.docComment.typeParams.add(docParamBlock); this._currentSection = docParamBlock.content; return; } else { const newBlock = new nodes_1.DocBlock({ configuration: this._configuration, blockTag: docBlockTag }); this._addBlockToDocComment(newBlock); this._currentSection = newBlock.content; } return; case TSDocTagDefinition_1.TSDocTagSyntaxKind.ModifierTag: // The block tag was recognized as a modifier, so add it to the modifier tag set // and do NOT call currentSection.appendNode(parsedNode) modifierTagSet.addTag(docBlockTag); return; } } this._pushNode(docBlockTag); } _addBlockToDocComment(block) { const docComment = this._parserContext.docComment; switch (block.blockTag.tagNameWithUpperCase) { case StandardTags_1.StandardTags.remarks.tagNameWithUpperCase: docComment.remarksBlock = block; break; case StandardTags_1.StandardTags.privateRemarks.tagNameWithUpperCase: docComment.privateRemarks = block; break; case StandardTags_1.StandardTags.deprecated.tagNameWithUpperCase: docComment.deprecatedBlock = block; break; case StandardTags_1.StandardTags.returns.tagNameWithUpperCase: docComment.returnsBlock = block; break; case StandardTags_1.StandardTags.see.tagNameWithUpperCase: docComment._appendSeeBlock(block); break; default: docComment.appendCustomBlock(block); break; } } /** * Used by `_parseParamBlock()`, this parses a JSDoc expression remainder like `string}` or `="]"]` from * an input like `@param {string} [x="]"] - the X value`. It detects nested balanced pairs of delimiters * and escaped string literals. */ _tryParseJSDocTypeOrValueRest(tokenReader, openKind, closeKind, startMarker) { let quoteKind; let openCount = 1; while (openCount > 0) { let tokenKind = tokenReader.peekTokenKind(); switch (tokenKind) { case openKind: // ignore open bracket/brace inside of a quoted string if (quoteKind === undefined) openCount++; break; case closeKind: // ignore close bracket/brace inside of a quoted string if (quoteKind === undefined) openCount--; break; case Token_1.TokenKind.Backslash: // ignore backslash outside of quoted string if (quoteKind !== undefined) { // skip the backslash and the next character. tokenReader.readToken(); tokenKind = tokenReader.peekTokenKind(); } break; case Token_1.TokenKind.DoubleQuote: case Token_1.TokenKind.SingleQuote: case Token_1.TokenKind.Backtick: if (quoteKind === tokenKind) { // exit quoted string if quote character matches. quoteKind = undefined; } else if (quoteKind === undefined) { // start quoted string if not in a quoted string. quoteKind = tokenKind; } break; } // give up at end of input and backtrack to start. if (tokenKind === Token_1.TokenKind.EndOfInput) { tokenReader.backtrackToMarker(startMarker); return undefined; } tokenReader.readToken(); } return tokenReader.tryExtractAccumulatedSequence(); } /** * Used by `_parseParamBlock()`, this parses a JSDoc expression like `{string}` from * an input like `@param {string} x - the X value`. */ _tryParseUnsupportedJSDocType(tokenReader, docBlockTag, tagName) { tokenReader.assertAccumulatedSequenceIsEmpty(); // do not parse `{@...` as a JSDoc type if (tokenReader.peekTokenKind() !== Token_1.TokenKind.LeftCurlyBracket || tokenReader.peekTokenAfterKind() === Token_1.TokenKind.AtSign) { return undefined; } const startMarker = tokenReader.createMarker(); tokenReader.readToken(); // read the "{" let jsdocTypeExcerpt = this._tryParseJSDocTypeOrValueRest(tokenReader, Token_1.TokenKind.LeftCurlyBracket, Token_1.TokenKind.RightCurlyBracket, startMarker); if (jsdocTypeExcerpt) { this._parserContext.log.addMessageForTokenSequence(TSDocMessageId_1.TSDocMessageId.ParamTagWithInvalidType, 'The ' + tagName + " block should not include a JSDoc-style '{type}'", jsdocTypeExcerpt, docBlockTag); const spacingAfterJsdocTypeExcerpt = this._tryReadSpacingAndNewlines(tokenReader); if (spacingAfterJsdocTypeExcerpt) { jsdocTypeExcerpt = jsdocTypeExcerpt.getNewSequence(jsdocTypeExcerpt.startIndex, spacingAfterJsdocTypeExcerpt.endIndex); } } return jsdocTypeExcerpt; } /** * Used by `_parseParamBlock()`, this parses a JSDoc expression remainder like `=[]]` from * an input like `@param {string} [x=[]] - the X value`. */ _tryParseJSDocOptionalNameRest(tokenReader) { tokenReader.assertAccumulatedSequenceIsEmpty(); if (tokenReader.peekTokenKind() !== Token_1.TokenKind.EndOfInput) { const startMarker = tokenReader.createMarker(); return this._tryParseJSDocTypeOrValueRest(tokenReader, Token_1.TokenKind.LeftSquareBracket, Token_1.TokenKind.RightSquareBracket, startMarker); } return undefined; } _parseParamBlock(tokenReader, docBlockTag, tagName) { const startMarker = tokenReader.createMarker(); const spacingBeforeParameterNameExcerpt = this._tryReadSpacingAndNewlines(tokenReader); // Skip past a JSDoc type (i.e., '@param {type} paramName') if found, and report a warning. const unsupportedJsdocTypeBeforeParameterNameExcerpt = this._tryParseUnsupportedJSDocType(tokenReader, docBlockTag, tagName); // Parse opening of invalid JSDoc optional parameter name (e.g., '[') let unsupportedJsdocOptionalNameOpenBracketExcerpt; if (tokenReader.peekTokenKind() === Token_1.TokenKind.LeftSquareBracket) { tokenReader.readToken(); // read the "[" unsupportedJsdocOptionalNameOpenBracketExcerpt = tokenReader.extractAccumulatedSequence(); } let parameterName = ''; let done = false; while (!done) { switch (tokenReader.peekTokenKind()) { case Token_1.TokenKind.AsciiWord: case Token_1.TokenKind.Period: case Token_1.TokenKind.DollarSign: parameterName += tokenReader.readToken(); break; default: done = true; break; } } const explanation = StringChecks_1.StringChecks.explainIfInvalidUnquotedIdentifier(parameterName); if (explanation !== undefined) { tokenReader.backtrackToMarker(startMarker); const errorParamBlock = new nodes_1.DocParamBlock({ configuration: this._configuration, blockTag: docBlockTag, parameterName: '' }); const errorMessage = parameterName.length > 0 ? 'The ' + tagName + ' block should be followed by a valid parameter name: ' + explanation : 'The ' + tagName + ' block should be followed by a parameter name'; this._parserContext.log.addMessageForTokenSequence(TSDocMessageId_1.TSDocMessageId.ParamTagWithInvalidName, errorMessage, docBlockTag.getTokenSequence(), docBlockTag); return errorParamBlock; } const parameterNameExcerpt = tokenReader.extractAccumulatedSequence(); // Parse closing of invalid JSDoc optional parameter name (e.g., ']', '=default]'). let unsupportedJsdocOptionalNameRestExcerpt; if (unsupportedJsdocOptionalNameOpenBracketExcerpt) { unsupportedJsdocOptionalNameRestExcerpt = this._tryParseJSDocOptionalNameRest(tokenReader); let errorSequence = unsupportedJsdocOptionalNameOpenBracketExcerpt; if (unsupportedJsdocOptionalNameRestExcerpt) { errorSequence = docBlockTag .getTokenSequence() .getNewSequence(unsupportedJsdocOptionalNameOpenBracketExcerpt.startIndex, unsupportedJsdocOptionalNameRestExcerpt.endIndex); } this._parserContext.log.addMessageForTokenSequence(TSDocMessageId_1.TSDocMessageId.ParamTagWithInvalidOptionalName, 'The ' + tagName + " should not include a JSDoc-style optional name; it must not be enclosed in '[ ]' brackets.", errorSequence, docBlockTag); } const spacingAfterParameterNameExcerpt = this._tryReadSpacingAndNewlines(tokenReader); // Skip past a trailing JSDoc type (i.e., '@param paramName {type}') if found, and report a warning. const unsupportedJsdocTypeAfterParameterNameExcerpt = this._tryParseUnsupportedJSDocType(tokenReader, docBlockTag, tagName); // TODO: Warn if there is no space before or after the hyphen let hyphenExcerpt; let spacingAfterHyphenExcerpt; let unsupportedJsdocTypeAfterHyphenExcerpt; if (tokenReader.peekTokenKind() === Token_1.TokenKind.Hyphen) { tokenReader.readToken(); hyphenExcerpt = tokenReader.extractAccumulatedSequence(); // TODO: Only read one space spacingAfterHyphenExcerpt = this._tryReadSpacingAndNewlines(tokenReader); // Skip past a JSDoc type (i.e., '@param paramName - {type}') if found, and report a warning. unsupportedJsdocTypeAfterHyphenExcerpt = this._tryParseUnsupportedJSDocType(tokenReader, docBlockTag, tagName); } else { this._parserContext.log.addMessageForTokenSequence(TSDocMessageId_1.TSDocMessageId.ParamTagMissingHyphen, 'The ' + tagName + ' block should be followed by a parameter name and then a hyphen', docBlockTag.getTokenSequence(), docBlockTag); } return new nodes_1.DocParamBlock({ parsed: true, configuration: this._configuration, blockTag: docBlockTag, spacingBeforeParameterNameExcerpt, unsupportedJsdocTypeBeforeParameterNameExcerpt, unsupportedJsdocOptionalNameOpenBracketExcerpt, parameterNameExcerpt, parameterName, unsupportedJsdocOptionalNameRestExcerpt, spacingAfterParameterNameExcerpt, unsupportedJsdocTypeAfterParameterNameExcerpt, hyphenExcerpt, spacingAfterHyphenExcerpt, unsupportedJsdocTypeAfterHyphenExcerpt }); } _pushNode(docNode) { if (this._configuration.docNodeManager.isAllowedChild(nodes_1.DocNodeKind.Paragraph, docNode.kind)) { this._currentSection.appendNodeInParagraph(docNode); } else { this._currentSection.appendNode(docNode); } } _parseBackslashEscape(tokenReader) { tokenReader.assertAccumulatedSequenceIsEmpty(); const marker = tokenReader.createMarker(); tokenReader.readToken(); // read the backslash if (tokenReader.peekTokenKind() === Token_1.TokenKind.EndOfInput) { return this._backtrackAndCreateError(tokenReader, marker, TSDocMessageId_1.TSDocMessageId.UnnecessaryBackslash, 'A backslash must precede another character that is being escaped'); } const escapedToken = tokenReader.readToken(); // read the escaped character // In CommonMark, a backslash is only allowed before a punctuation // character. In all other contexts, the backslash is interpreted as a // literal character. if (!Tokenizer_1.Tokenizer.isPunctuation(escapedToken.kind)) { return this._backtrackAndCreateError(tokenReader, marker, TSDocMessageId_1.TSDocMessageId.UnnecessaryBackslash, 'A backslash can only be used to escape a punctuation character'); } const encodedTextExcerpt = tokenReader.extractAccumulatedSequence(); return new nodes_1.DocEscapedText({ parsed: true, configuration: this._configuration, escapeStyle: nodes_1.EscapeStyle.CommonMarkBackslash, encodedTextExcerpt, decodedText: escapedToken.toString() }); } _parseBlockTag(tokenReader) { tokenReader.assertAccumulatedSequenceIsEmpty(); const marker = tokenReader.createMarker(); if (tokenReader.peekTokenKind() !== Token_1.TokenKind.AtSign) { return this._backtrackAndCreateError(tokenReader, marker, TSDocMessageId_1.TSDocMessageId.MissingTag, 'Expecting a TSDoc tag starting with "@"'); } // "@one" is a valid TSDoc tag at the start of a line, but "@one@two" is // a syntax error. For two tags it should be "@one @two", or for literal text it // should be "\@one\@two". switch (tokenReader.peekPreviousTokenKind()) { case Token_1.TokenKind.EndOfInput: case Token_1.TokenKind.Spacing: case Token_1.TokenKind.Newline: break; default: return this._backtrackAndCreateError(tokenReader, marker, TSDocMessageId_1.TSDocMessageId.AtSignInWord, 'The "@" character looks like part of a TSDoc tag; use a backslash to escape it'); } // Include the "@" as part of the tagName let tagName = tokenReader.readToken().toString(); if (tokenReader.peekTokenKind() !== Token_1.TokenKind.AsciiWord) { return this._backtrackAndCreateError(tokenReader, marker, TSDocMessageId_1.TSDocMessageId.AtSignWithoutTagName, 'Expecting a TSDoc tag name after "@"; if it is not a tag, use a backslash to escape this character'); } const tagNameMarker = tokenReader.createMarker(); while (tokenReader.peekTokenKind() === Token_1.TokenKind.AsciiWord) { tagName += tokenReader.readToken().toString(); } switch (tokenReader.peekTokenKind()) { case Token_1.TokenKind.Spacing: case Token_1.TokenKind.Newline: case Token_1.TokenKind.EndOfInput: break; default: const badCharacter = tokenReader.peekToken().range.toString()[0]; return this._backtrackAndCreateError(tokenReader, marker, TSDocMessageId_1.TSDocMessageId.CharactersAfterBlockTag, `The token "${tagName}" looks like a TSDoc tag but contains an invalid character` + ` ${JSON.stringify(badCharacter)}; if it is not a tag, use a backslash to escape the "@"`); } if (StringChecks_1.StringChecks.explainIfInvalidTSDocTagName(tagName)) { const failure = this._createFailureForTokensSince(tokenReader, TSDocMessageId_1.TSDocMessageId.MalformedTagName, 'A TSDoc tag name must start with a letter and contain only letters and numbers', tagNameMarker); return this._backtrackAndCreateErrorForFailure(tokenReader, marker, '', failure); } return new nodes_1.DocBlockTag({ parsed: true, configuration: this._configuration, tagName, tagNameExcerpt: tokenReader.extractAccumulatedSequence() }); } _parseInlineTag(tokenReader) { tokenReader.assertAccumulatedSequenceIsEmpty(); const marker = tokenReader.createMarker(); if (tokenReader.peekTokenKind() !== Token_1.TokenKind.LeftCurlyBracket) { return this._backtrackAndCreateError(tokenReader, marker, TSDocMessageId_1.TSDocMessageId.MissingTag, 'Expecting a TSDoc tag starting with "{"'); } tokenReader.readToken(); const openingDelimiterExcerpt = tokenReader.extractAccumulatedSequence(); // For inline tags, if we handle errors by backtracking to the "{" token, then the main loop // will then interpret the "@" as a block tag, which is almost certainly incorrect. So the // DocErrorText needs to include both the "{" and "@" tokens. // We will use _backtrackAndCreateErrorRangeForFailure() for that. const atSignMarker = tokenReader.createMarker(); if (tokenReader.peekTokenKind() !== Token_1.TokenKind.AtSign) { return this._backtrackAndCreateError(tokenReader, marker, TSDocMessageId_1.TSDocMessageId.MalformedInlineTag, 'Expecting a TSDoc tag starting with "{@"'); } // Include the "@" as part of the tagName let tagName = tokenReader.readToken().toString(); while (tokenReader.peekTokenKind() === Token_1.TokenKind.AsciiWord) { tagName += tokenReader.readToken().toString(); } if (tagName === '@') { // This is an unusual case const failure = this._createFailureForTokensSince(tokenReader, TSDocMessageId_1.TSDocMessageId.MalformedInlineTag, 'Expecting a TSDoc inline tag name after the "{@" characters', atSignMarker); return this._backtrackAndCreateErrorRangeForFailure(tokenReader, marker, atSignMarker, '', failure); } if (StringChecks_1.StringChecks.explainIfInvalidTSDocTagName(tagName)) { const failure = this._createFailureForTokensSince(tokenReader, TSDocMessageId_1.TSDocMessageId.MalformedTagName, 'A TSDoc tag name must start with a letter and contain only letters and numbers', atSignMarker); return this._backtrackAndCreateErrorRangeForFailure(tokenReader, marker, atSignMarker, '', failure); } const tagNameExcerpt = tokenReader.extractAccumulatedSequence(); const spacingAfterTagNameExcerpt = this._tryReadSpacingAndNewlines(tokenReader); if (spacingAfterTagNameExcerpt === undefined) { // If there were no spaces at all, that's an error unless it's the degenerate "{@tag}" case if (tokenReader.peekTokenKind() !== Token_1.TokenKind.RightCurlyBracket) { const badCharacter = tokenReader.peekToken().range.toString()[0]; const failure = this._createFailureForToken(tokenReader, TSDocMessageId_1.TSDocMessageId.CharactersAfterInlineTag, `The character ${JSON.stringify(badCharacter)} cannot appear after the TSDoc tag name; expecting a space`); return this._backtrackAndCreateErrorRangeForFailure(tokenReader, marker, atSignMarker, '', failure); } } let done = false; while (!done) { switch (tokenReader.peekTokenKind()) { case Token_1.TokenKind.EndOfInput: return this._backtrackAndCreateErrorRange(tokenReader, marker, atSignMarker, TSDocMessageId_1.TSDocMessageId.InlineTagMissingRightBrace, 'The TSDoc inline tag name is missing its closing "}"'); case Token_1.TokenKind.Backslash: // http://usejsdoc.org/about-block-inline-tags.html // "If your tag's text includes a closing curly brace (}), you must escape it with // a leading backslash (\)." tokenReader.readToken(); // discard the backslash // In CommonMark, a backslash is only allowed before a punctuation // character. In all other contexts, the backslash is interpreted as a // literal character. if (!Tokenizer_1.Tokenizer.isPunctuation(tokenReader.peekTokenKind())) { const failure = this._createFailureForToken(tokenReader, TSDocMessageId_1.TSDocMessageId.UnnecessaryBackslash, 'A backslash can only be used to escape a punctuation character'); return this._backtrackAndCreateErrorRangeForFailure(tokenReader, marker, atSignMarker, 'Error reading inline TSDoc tag: ', failure); } tokenReader.readToken(); break; case Token_1.TokenKind.LeftCurlyBracket: { const failure = this._createFailureForToken(tokenReader, TSDocMessageId_1.TSDocMessageId.InlineTagUnescapedBrace, 'The "{" character must be escaped with a backslash when used inside a TSDoc inline tag'); return this._backtrackAndCreateErrorRangeForFailure(tokenReader, marker, atSignMarker, '', failure); } case Token_1.TokenKind.RightCurlyBracket: done = true; break; default: tokenReader.readToken(); break; } } const tagContentExcerpt = tokenReader.tryExtractAccumulatedSequence(); // Read the right curly bracket tokenReader.readToken(); const closingDelimiterExcerpt = tokenReader.extractAccumulatedSequence(); const docInlineTagParsedParameters = { parsed: true, configuration: this._configuration, openingDelimiterExcerpt, tagNameExcerpt, tagName, spacingAfterTagNameExcerpt, tagContentExcerpt, closingDelimiterExcerpt }; const tagNameWithUpperCase = tagName.toUpperCase(); // Create a new TokenReader that will reparse the tokens corresponding to the tagContent. const embeddedTokenReader = new TokenReader_1.TokenReader(this._parserContext, tagContentExcerpt ? tagContentExcerpt : TokenSequence_1.TokenSequence.createEmpty(this._parserContext)); let docNode; switch (tagNameWithUpperCase) { case StandardTags_1.StandardTags.inheritDoc.tagNameWithUpperCase: docNode = this._parseInheritDocTag(docInlineTagParsedParameters, embeddedTokenReader); break; case StandardTags_1.StandardTags.link.tagNameWithUpperCase: docNode = this._parseLinkTag(docInlineTagParsedParameters, embeddedTokenReader); break; default: docNode = new nodes_1.DocInlineTag(docInlineTagParsedParameters); } // Validate the tag const tagDefinition = this._parserContext.configuration.tryGetTagDefinitionWithUpperCase(tagNameWithUpperCase); this._validateTagDefinition(tagDefinition, tagName, /* expectingInlineTag */ true, tagNameExcerpt, docNode); return docNode; } _parseInheritDocTag(docInlineTagParsedParameters, embeddedTokenReader) { // If an error occurs, then return a generic DocInlineTag instead of DocInheritDocTag const errorTag = new nodes_1.DocInlineTag(docInlineTagParsedParameters); const parameters = { ...docInlineTagParsedParameters }; if (embeddedTokenReader.peekTokenKind() !== Token_1.TokenKind.EndOfInput) { parameters.declarationReference = this._parseDeclarationReference(embeddedTokenReader, docInlineTagParsedParameters.tagNameExcerpt, errorTag); if (!parameters.declarationReference) { return errorTag; } if (embeddedTokenReader.peekTokenKind() !== Token_1.TokenKind.EndOfInput) { embeddedTokenReader.readToken(); this._parserContext.log.addMessageForTokenSequence(TSDocMessageId_1.TSDocMessageId.InheritDocTagSyntax, 'Unexpected character after declaration reference', embeddedTokenReader.extractAccumulatedSequence(), errorTag); return errorTag; } } return new nodes_1.DocInheritDocTag(parameters); } _parseLinkTag(docInlineTagParsedParameters, embeddedTokenReader) { // If an error occurs, then return a generic DocInlineTag instead of DocInheritDocTag const errorTag = new nodes_1.DocInlineTag(docInlineTagParsedParameters); const parameters = { ...docInlineTagParsedParameters }; if (!docInlineTagParsedParameters.tagContentExcerpt) { this._parserContext.log.addMessageForTokenSequence(TSDocMessageId_1.TSDocMessageId.LinkTagEmpty, 'The @link tag content is missing', parameters.tagNameExcerpt, errorTag); return errorTag; } // Is the link destination a URL or a declaration reference? // // The JSDoc "@link" tag allows URLs, however supporting full URLs would be highly // ambiguous, for example "microsoft.windows.camera:" is an actual valid URI scheme, // and even the common "mailto:example.com" looks suspiciously like a declaration reference. // In practice JSDoc URLs are nearly always HTTP or HTTPS, so it seems fairly reasonable to // require the URL to have "://" and a scheme without any punctuation in it. If a more exotic // URL is needed, the HTML "<a>" tag can always be used. // We start with a fairly broad classifier heuristic, and then the parsers will refine this: // 1. Does it start with "//"? // 2. Does it contain "://"? let looksLikeUrl = embeddedTokenReader.peekTokenKind() === Token_1.TokenKind.Slash && embeddedTokenReader.peekTokenAfterKind() === Token_1.TokenKind.Slash; const marker = embeddedTokenReader.createMarker(); let done = looksLikeUrl; while (!done) { switch (embeddedTokenReader.peekTokenKind()) { // An URI scheme can contain letters, numbers, minus, plus, and periods case Token_1.TokenKind.AsciiWord: case Token_1.TokenKind.Period: case Token_1.TokenKind.Hyphen: case Token_1.TokenKind.Plus: embeddedTokenReader.readToken(); break; case Token_1.TokenKind.Colon: embeddedTokenReader.readToken(); // Once we a reach a colon, then it's a URL only if we see "://" looksLikeUrl = embeddedTokenReader.peekTokenKind() === Token_1.TokenKind.Slash && embeddedTokenReader.peekTokenAfterKind() === Token_1.TokenKind.Slash; done = true; break; default: done = true; } } embeddedTokenReader.backtrackToMarker(marker); // Is the hyperlink a URL or a declaration reference? if (looksLikeUrl) { // It starts with something like "http://", so parse it as a URL if (!this._parseLinkTagUrlDestination(embeddedTokenReader, parameters, docInlineTagParsedParameters.tagNameExcerpt, errorTag)) { return errorTag; } } else { // Otherwise, assume it's a declaration reference if (!this._parseLinkTagCodeDestination(embeddedTokenReader, parameters, docInlineTagParsedParameters.tagNameExcerpt, errorTag)) { return errorTag; } } if (embeddedTokenReader.peekTokenKind() === Token_1.TokenKind.Spacing) { // The above parser rules should have consumed any spacing before the pipe throw new Error('Unconsumed spacing encountered after construct'); } if (embeddedTokenReader.peekTokenKind() === Token_1.TokenKind.Pipe) { // Read the link text embeddedTokenReader.readToken(); parameters.pipeExcerpt = embeddedTokenReader.extractAccumulatedSequence(); parameters.spacingAfterPipeExcerpt = this._tryReadSpacingAndNewlines(embeddedTokenReader); // Read everything until the end // NOTE: Because we're using an embedded TokenReader, the TokenKind.EndOfInput occurs // when we reach the "}", not the end of the original input done = false; let spacingAfterLinkTextMarker = undefined; while (!done) { switch (embeddedTokenReader.peekTokenKind()) { case Token_1.TokenKind.EndOfInput: done = true; break; case Token_1.TokenKind.Pipe: case Token_1.TokenKind.LeftCurlyBracket: const badCharacter = embeddedTokenReader.readToken().toString(); this._parserContext.log.addMessageForTokenSequence(TSDocMessageId_1.TSDocMessageId.LinkTagUnescapedText, `The "${badCharacter}" character may not be used in the link text without escaping it`, embeddedTokenReader.extractAccumulatedSequence(), errorTag); return errorTag; case Token_1.TokenKind.Spacing: case Token_1.TokenKind.Newline: embeddedTokenReader.readToken(); break; default: // We found a non-spacing character, so move the spacingAfterLinkTextMarker spacingAfterLinkTextMarker = embeddedTokenReader.createMarker() + 1; embeddedTokenReader.readToken(); } } const linkTextAndSpacing = embeddedTokenReader.tryExtractAccumulatedSequence(); if (linkTextAndSpacing) { if (spacingAfterLinkTextMarker === undefined) { // We never found any non-spacing characters, so everything is trailing spacing parameters.spacingAfterLinkTextExcerpt = linkTextAndSpacing; } else if (spacingAfterLinkTextMarker >= linkTextAndSpacing.endIndex) { // We found no trailing spacing, so everything we found is the text parameters.linkTextExcerpt = linkTextAndSpacing; } else { // Split the trailing spacing from the link text parameters.linkTextExcerpt = linkTextAndSpacing.getNewSequence(linkTextAndSpacing.startIndex, spacingAfterLinkTextMarker); parameters.spacingAfterLinkTextExcerpt = linkTextAndSpacing.getNewSequence(spacingAfterLinkTextMarker, linkTextAndSpacing.endIndex); } } } else if (embeddedTokenReader.peekTokenKind() !== Token_1.TokenKind.EndOfInput) { embeddedTokenReader.readToken(); this._parserContext.log.addMessageForTokenSequence(TSDocMessageId_1.TSDocMessageId.LinkTagDestinationSyntax, 'Unexpected character after link destination', embeddedTokenReader.extractAccumulatedSequence(), errorTag); return errorTag; } return new nodes_1.DocLinkTag(parameters); } _parseLinkTagUrlDestination(embeddedTokenReader, parameters, tokenSequenceForErrorContext, nodeForErrorContext) { // Simply accumulate everything up to the next space. We won't try to implement a proper // URI parser here. let urlDestination = ''; let done = false; while (!done) { switch (embeddedTokenReader.peekTokenKind()) { case Token_1.TokenKind.Spacing: case Token_1.TokenKind.Newline: case Token_1.TokenKind.EndOfInput: case Token_1.TokenKind.Pipe: case Token_1.TokenKind.RightCurlyBracket: done = true; break; default: urlDestination += embeddedTokenReader.readToken(); break; } } if (urlDestination.length === 0) { // This should be impossible since the caller ensures that peekTokenKind() === TokenKind.AsciiWord throw new Error('Missing URL in _parseLinkTagUrlDestination()'); } const urlDestinationExcerpt = embeddedTokenReader.extractAccumulatedSequence(); const invalidUrlExplanation = StringChecks_1.StringChecks.explainIfInvalidLinkUrl(urlDestination); if (invalidUrlExplanation) { this._parserContext.log.addMessageForTokenSequence(TSDocMessageId_1.TSDocMessageId.LinkTagInvalidUrl, invalidUrlExplanation, urlDestinationExcerpt, nodeForErrorContext); return false; } parameters.urlDestinationExcerpt = urlDestinationExcerpt; parameters.spacingAfterDestinationExcerpt = this._tryReadSpacingAndNewlines(embeddedTokenReader); return true; } _parseLinkTagCodeDestination(embeddedTokenReader, parameters, tokenSequenceForErrorContext, nodeForErrorContext) { parameters.codeDestination = this._parseDeclarationReference(embeddedTokenReader, tokenSequenceForErrorContext, nodeForErrorContext); return !!parameters.codeDestination; } _parseDeclarationReference(tokenReader, tokenSequenceForErrorContext, nodeForErrorContext) { tokenReader.assertAccumulatedSequenceIsEmpty(); // The package name can contain characters that look like a member reference. This means we need to scan forwards // to see if there is a "#". However, we need to be careful not to match a "#" that is part of a quoted expression. const marker = tokenReader.createMarker(); let hasHash = false; // A common mistake is to forget the "#" for package name or import path. The telltale sign // of this is mistake is that we see path-only characters such as "@" or "/" in the beginning // where this would be a syntax error for a member reference. let lookingForImportCharacters = true; let sawImportCharacters = false; let done = false; while (!done) { switch (tokenReader.peekTokenKind()) { case Token_1.TokenKind.DoubleQuote: case Token_1.TokenKind.EndOfInput: case Token_1.TokenKind.LeftCurlyBracket: case Token_1.TokenKind.LeftParenthesis: case Token_1.TokenKind.LeftSquareBracket: case Token_1.TokenKind.Newline: case Token_1.TokenKind.Pipe: case Token_1.TokenKind.RightCurlyBracket: case Token_1.TokenKind.RightParenthesis: case Token_1.TokenKind.RightSquareBracket: case Token_1.TokenKind.SingleQuote: case Token_1.TokenKind.Spacing: done = true; break; case Token_1.TokenKind.PoundSymbol: hasHash = true; done = true; break; case Token_1.TokenKind.Slash: case Token_1.TokenKind.AtSign: if (lookingForImportCharacters) { sawImportCharacters = true; } tokenReader.readToken(); break; case Token_1.TokenKind.AsciiWord: case Token_1.TokenKind.Period: case Token_1.TokenKind.Hyphen: // It's a character that looks like part of a package name or import path, // so don't set lookingForImportCharacters = false tokenReader.readToken(); break; default: // Once we reach something other than AsciiWord and Period, then the meaning of // slashes and at-signs is no longer obvious. lookingForImportCharacters = false; tokenReader.readToken(); } } if (!hasHash && sawImportCharacters) { // We saw characters that will be a syntax error if interpreted as a member reference, // but would make sense as a package name or import path, but we did not find a "#" this._parserContext.log.addMessageForTokenSequence(TSDocMessageId_1.TSDocMessageId.ReferenceMissingHash, 'The declaration reference appears to contain a package name or import path,' + ' but it is missing the "#" delimiter', tokenReader.extractAccumulatedSequence(), nodeForErrorContext); return undefined; } tokenReader.backtrackToMarker(marker); let packageNameExcerpt; let importPathExcerpt; let importHashExcerpt; let spacingAfterImportHashExcerpt; if (hasHash) { // If it starts with a "." then it's a relative path, not a package name if (tokenReader.peekTokenKind() !== Token_1.TokenKind.Period) { // Read the package name: const scopedPackageName = tokenReader.peekTokenKind() === Token_1.TokenKind.AtSign; let finishedScope = false; done = false; while (!done) { switch (tokenReader.peekTokenKind()) { case Token_1.TokenKind.EndOfInput: // If hasHash=true, then we are expecting to stop when we reach the hash throw new Error('Expecting pound symbol'); case Token_1.TokenKind.Slash: // Stop at the first slash, unless this is a scoped package, in which case we stop at the second slash if (scopedPackageName && !finishedScope) { tokenReader.readToken(); finishedScope = true; } else { done = true; } break; case Token_1.TokenKind.PoundSymbol: done = true; break; default: tokenReader.readToken(); }