From db572109629da9c89c65bbcb4e7a2819c8a35527 Mon Sep 17 00:00:00 2001 From: Mark Pearce Date: Thu, 1 Oct 2026 18:51:36 -0300 Subject: [PATCH] fix: keep multi-line param and return descriptions with their tags Fixes #11 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + examples/source/MultiLineDescriptions.bs | 10 ++++ src/convert-brighterscript-docs.spec.ts | 30 ++++++++++++ src/convert-brighterscript-docs.ts | 60 ++++++++++++++++++++---- 4 files changed, 92 insertions(+), 9 deletions(-) create mode 100644 examples/source/MultiLineDescriptions.bs diff --git a/CHANGELOG.md b/CHANGELOG.md index e192207..e9a64b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Enum and const values containing BrightScript hex literals (`&hFF0000FF`) are now normalized to valid JS syntax (`0xFF0000FF`) before being emitted, instead of breaking jsdoc's parser ([#14](https://github.com/markwpearce/brighterscript-jsdocs-plugin/issues/14)) - Class fields with an object literal default value (`foo = {}`) no longer crash with `this.getLookupTable is not a function` (fixed upstream in `brighterscript@1.0.0-alpha.56`) ([#17](https://github.com/markwpearce/brighterscript-jsdocs-plugin/issues/17)) +- Multi-line `@param` and `@returns` descriptions now stay with their tag instead of being appended to the function's description. Lines following one of those tags (up to the next tag or a blank comment line) are treated as part of its description ([#11](https://github.com/markwpearce/brighterscript-jsdocs-plugin/issues/11)) ### Added diff --git a/examples/source/MultiLineDescriptions.bs b/examples/source/MultiLineDescriptions.bs new file mode 100644 index 0000000..c678e0a --- /dev/null +++ b/examples/source/MultiLineDescriptions.bs @@ -0,0 +1,10 @@ +' Moves an entity towards a target position +' @param {object} entity the entity to move. It must have +' x and y properties +' @param speed how far to move per second, which is capped +' at the entity's max speed +' @return true if the entity reached the target +' during this move +function moveTowards(entity as object, speed as float) as boolean + return false +end function diff --git a/src/convert-brighterscript-docs.spec.ts b/src/convert-brighterscript-docs.spec.ts index 2ad7c9e..6e2ff80 100644 --- a/src/convert-brighterscript-docs.spec.ts +++ b/src/convert-brighterscript-docs.spec.ts @@ -72,6 +72,36 @@ describe('convertBrighterscriptDocs', () => { `); }); + it('keeps multi-line param and return descriptions with their tags', () => { + expectOutput(cbd.convertBrighterscriptDocs(` + ' Say hello + ' @param {string} name you want to say hello to + ' which can be long + ' @param count + ' how many times to say it + ' @returns the greeting + ' repeated count times + ' + ' More description after a blank line + function sayHello(name as string, count as integer) as string + return "Hello, " + name + "!") + end function + `), ` + /** + * Say hello + * + * More description after a blank line + * @function + * @param {string} name you want to say hello to + * which can be long + * @param {integer} count how many times to say it + * @returns {string} the greeting + * repeated count times + */ + function sayHello (name, count) { }; + `); + }); + it('uses comment type over given type', () => { expectOutput(cbd.convertBrighterscriptDocs(` ' Say hello diff --git a/src/convert-brighterscript-docs.ts b/src/convert-brighterscript-docs.ts index dd34dff..d3e044e 100644 --- a/src/convert-brighterscript-docs.ts +++ b/src/convert-brighterscript-docs.ts @@ -8,6 +8,8 @@ const paramRegexNoType = /@param\s+(?:\[(\w+).*\]|(\w+))[\s\-\s|\s]*(.*)/; const returnRegex = /@returns?\s*({(?:[^}]*)})?\s*(.*)/; const extendsRegex = /@extends/; const moduleRegex = /@module ([^\*\s]+)/; +const paramOrReturnTagRegex = /^ \* @(?:param|returns?)\b/; +const tagContinuationRegex = /^ \* [^@\s]/; const escapeCharEntities = { '&': '&', '<': '<', @@ -178,6 +180,43 @@ function paramOrReturnDescriptionHelper(desc = '') { return ''; } +/** + * Folds the lines that follow a `@param` or `@returns` tag (up to the next tag or blank line) into that + * tag's line, separated by newlines. Those tags get moved below the description, so without this any + * multi-line description would be left behind in the main description. + * + * @param commentLines jsdoc comment lines, as returned from convertCommentTextToJsDocLines() + * @returns the comment lines, with continuation lines merged into their tags + */ +function mergeTagContinuationLines(commentLines: string[]) { + const output: string[] = []; + let inParamOrReturnTag = false; + for (const line of commentLines) { + if (inParamOrReturnTag && tagContinuationRegex.test(line)) { + output[output.length - 1] += '\n' + line; + continue; + } + inParamOrReturnTag = paramOrReturnTagRegex.test(line); + output.push(line); + } + return output; +} + +/** + * Splits a (possibly merged) tag line into the tag line itself and the text of its continuation lines + */ +function splitTagLine(line: string) { + const [tagLine, ...continuationLines] = line.split('\n'); + return { tagLine: tagLine, continuationText: continuationLines.map(continuation => continuation.replace(/^ \* /, '')) }; +} + +/** + * Joins the first line of a tag's description with its continuation lines, each on its own jsdoc line + */ +function joinDescriptionLines(firstLine = '', continuationText: string[] = []) { + return [paramOrReturnDescriptionHelper(firstLine), ...continuationText].filter(line => line).join('\n * '); +} + function getMemberOf(moduleName = '', namespaceName = '') { const memberOf = namespaceName || moduleName; const memberType = namespaceName ? '' : 'module:'; @@ -250,7 +289,7 @@ function convertCommentTextToJsDocLines(commentLines: string[] = []) { */ function processFunction(func: bs.FunctionStatement | bs.InterfaceMethodStatement | bs.MethodStatement, moduleName = '', namespaceName = '') { const output: string[] = []; - let commentLines = convertCommentTextToJsDocLines(getCommentLines(func)); + let commentLines = mergeTagContinuationLines(convertCommentTextToJsDocLines(getCommentLines(func))); const paramNameList: string[] = []; const params = bs.isInterfaceMethodStatement(func) ? func.params : func.func.parameters; let returnTypeExpression = bs.isInterfaceMethodStatement(func) ? func.returnTypeExpression : func.func.returnTypeExpression; @@ -275,7 +314,8 @@ function processFunction(func: bs.FunctionStatement | bs.InterfaceMethodStatemen // remove @param lines for the current param commentLines = commentLines.filter(commentLine => { - let commentMatch = paramRegex.exec(commentLine); + const { tagLine, continuationText } = splitTagLine(commentLine); + let commentMatch = paramRegex.exec(tagLine); if (commentMatch) { const commentParamName = (commentMatch[2] || commentMatch[3]) || ''; @@ -285,17 +325,17 @@ function processFunction(func: bs.FunctionStatement | bs.InterfaceMethodStatemen // same parameter name - use these details if (commentParamType) { paramType = commentParamType.trim(); - paramDescription = commentMatch[4] || paramDescription; + paramDescription = joinDescriptionLines(commentMatch[4], continuationText) || paramDescription; } return false; } } else { - commentMatch = paramRegexNoType.exec(commentLine); + commentMatch = paramRegexNoType.exec(tagLine); if (commentMatch) { const commentParamName = (commentMatch[1] || commentMatch[2]) || ''; if (paramName.trim().toLowerCase() === commentParamName.trim().toLowerCase()) { // same parameter name - use these details - paramDescription = commentMatch[3] || paramDescription; + paramDescription = joinDescriptionLines(commentMatch[3], continuationText) || paramDescription; return false; } } @@ -315,7 +355,7 @@ function processFunction(func: bs.FunctionStatement | bs.InterfaceMethodStatemen } if (paramDescription) { - paramLine += ` ${paramOrReturnDescriptionHelper(paramDescription)}`; + paramLine += ` ${paramDescription}`; } output.push(paramLine); } @@ -334,7 +374,8 @@ function processFunction(func: bs.FunctionStatement | bs.InterfaceMethodStatemen let returnLine = ` * @returns {${returnTypeString}}`; // Find the return line in the comments for (let i = 0; i < commentLines.length; i++) { - let commentMatch = returnRegex.exec(commentLines[i]); + const { tagLine, continuationText } = splitTagLine(commentLines[i]); + let commentMatch = returnRegex.exec(tagLine); if (commentMatch) { let commentReturnType = returnTypeString; if (commentMatch[1] && commentMatch[1].trim().toLowerCase() === commentReturnType.toLowerCase()) { @@ -342,8 +383,9 @@ function processFunction(func: bs.FunctionStatement | bs.InterfaceMethodStatemen commentReturnType = commentMatch[1].trim(); } returnLine = ` * @returns {${commentReturnType}}`; - if (commentMatch[2]) { - returnLine += ' ' + paramOrReturnDescriptionHelper(commentMatch[2]); + const returnDescription = joinDescriptionLines(commentMatch[2], continuationText); + if (returnDescription) { + returnLine += ' ' + returnDescription; } // remove the original comment @returns line commentLines.splice(i, 1);