diff --git a/docs/rules/check-line-alignment.md b/docs/rules/check-line-alignment.md index 08f2b11c9..d3ae7c6e1 100644 --- a/docs/rules/check-line-alignment.md +++ b/docs/rules/check-line-alignment.md @@ -1053,5 +1053,75 @@ function quux () { */ const fn = ( lorem ) => {} // "jsdoc/check-line-alignment": ["error"|"warn", "any",{"disableWrapIndent":true,"wrapIndent":" "}] + +/** + * @return {Promise} A promise. + * - On success, resolves. + * - On error, rejects with details: + * - When aborted, textStatus is "abort". + * - On timeout, textStatus is "timeout". + */ +function test() {} +// "jsdoc/check-line-alignment": ["error"|"warn", "never",{"wrapIndent":" "}] + +/** + * @param {string} lorem Description with list: + * - First item + * - Second item + * - Nested item + * - Another nested item + */ +function test() {} +// "jsdoc/check-line-alignment": ["error"|"warn", "never",{"wrapIndent":" "}] + +/** + * @return {Promise} A promise. + * 1. First step + * 2. Second step with continuation + * on another line + * 3. Third step + */ +function test() {} +// "jsdoc/check-line-alignment": ["error"|"warn", "never",{"wrapIndent":" "}] + +/** + * @param {Object} options Configuration options. + * * First option + * * Second option with details: + * * Nested detail + * * Another detail + */ +function test() {} +// "jsdoc/check-line-alignment": ["error"|"warn", "never",{"wrapIndent":" "}] + +/** + * @param {string} param Description with list: + * - Item 1 + * - Nested item + */ +function test(param) {} +// "jsdoc/check-line-alignment": ["error"|"warn", "always",{"wrapIndent":" "}] + +/** + * Function description. + * + * @param {string} lorem Description. + * @param {int} sit Description with list: + * - First item + * - Second item + * - Nested item + */ +const fn = ( lorem, sit ) => {} +// "jsdoc/check-line-alignment": ["error"|"warn", "always",{"wrapIndent":" "}] + +/** + * @return {Promise} A promise. + * - On success, resolves. + * - On error, rejects with details: + * - When aborted, status is "abort". + * - On timeout, status is "timeout". + */ +function test() {} +// "jsdoc/check-line-alignment": ["error"|"warn", "always",{"wrapIndent":" "}] ```` diff --git a/src/alignTransform.js b/src/alignTransform.js index 541832b18..effe1e555 100644 --- a/src/alignTransform.js +++ b/src/alignTransform.js @@ -9,6 +9,27 @@ import { util, } from 'comment-parser'; +/** + * Detects if a line starts with a markdown list marker + * Supports: -, *, numbered lists (1., 2., etc.) + * This explicitly excludes hyphens that are part of JSDoc tag syntax + * @param {string} text - The text to check + * @param {boolean} isFirstLineOfTag - True if this is the first line (tag line) + * @returns {boolean} - True if the text starts with a list marker + */ +const startsWithListMarker = (text, isFirstLineOfTag = false) => { + // On the first line of a tag, the hyphen is typically the JSDoc separator, + // not a list marker + if (isFirstLineOfTag) { + return false; + } + + // Match lines that start with optional whitespace, then a list marker + // - or * followed by a space + // or a number followed by . or ) and a space + return /^\s*(?:[\-*]|\d+(?:\.|\)))\s+/v.test(text); +}; + /** * @typedef {{ * hasNoTypes: boolean, @@ -144,6 +165,59 @@ const space = (len) => { return ''.padStart(len, ' '); }; +/** + * Check if a tag or any of its lines contain list markers + * @param {import('./iterateJsdoc.js').Integer} index - Current line index + * @param {import('comment-parser').Line[]} source - All source lines + * @returns {{hasListMarker: boolean, tagStartIndex: import('./iterateJsdoc.js').Integer}} + */ +const checkForListMarkers = (index, source) => { + let hasListMarker = false; + let tagStartIndex = index; + while (tagStartIndex > 0 && source[tagStartIndex].tokens.tag === '') { + tagStartIndex--; + } + + for (let idx = tagStartIndex; idx <= index; idx++) { + const isFirstLine = (idx === tagStartIndex); + if (source[idx]?.tokens?.description && startsWithListMarker(source[idx].tokens.description, isFirstLine)) { + hasListMarker = true; + break; + } + } + + return { + hasListMarker, + tagStartIndex, + }; +}; + +/** + * Calculate extra indentation for list items relative to the first continuation line + * @param {import('./iterateJsdoc.js').Integer} index - Current line index + * @param {import('./iterateJsdoc.js').Integer} tagStartIndex - Index of the tag line + * @param {import('comment-parser').Line[]} source - All source lines + * @returns {string} - Extra indentation spaces + */ +const calculateListExtraIndent = (index, tagStartIndex, source) => { + // Find the first continuation line to use as baseline + let firstContinuationIndent = null; + for (let idx = tagStartIndex + 1; idx < source.length; idx++) { + if (source[idx].tokens.description && !source[idx].tokens.tag) { + firstContinuationIndent = source[idx].tokens.postDelimiter.length; + break; + } + } + + // Calculate the extra indentation of current line relative to the first continuation line + const currentOriginalIndent = source[index].tokens.postDelimiter.length; + const extraIndent = firstContinuationIndent !== null && currentOriginalIndent > firstContinuationIndent ? + ' '.repeat(currentOriginalIndent - firstContinuationIndent) : + ''; + + return extraIndent; +}; + /** * @param {{ * customSpacings: import('../src/rules/checkLineAlignment.js').CustomSpacings, @@ -316,8 +390,20 @@ const alignTransform = ({ // Not align. if (shouldAlign(tags, index, source)) { alignTokens(tokens, typelessInfo); + if (!disableWrapIndent && indentTag) { - tokens.postDelimiter += wrapIndent; + const { + hasListMarker, + tagStartIndex, + } = checkForListMarkers(index, source); + + if (hasListMarker && index > tagStartIndex) { + const extraIndent = calculateListExtraIndent(index, tagStartIndex, source); + tokens.postDelimiter += wrapIndent + extraIndent; + } else { + // Normal case: add wrapIndent after the aligned delimiter + tokens.postDelimiter += wrapIndent; + } } } diff --git a/src/rules/checkLineAlignment.js b/src/rules/checkLineAlignment.js index 10929b8a9..9d16e0358 100644 --- a/src/rules/checkLineAlignment.js +++ b/src/rules/checkLineAlignment.js @@ -8,6 +8,54 @@ const { flow: commentFlow, } = transforms; +/** + * Detects if a line starts with a markdown list marker + * Supports: -, *, numbered lists (1., 2., etc.) + * This explicitly excludes hyphens that are part of JSDoc tag syntax + * @param {string} text - The text to check + * @param {boolean} isFirstLineOfTag - True if this is the first line (tag line) + * @returns {boolean} - True if the text starts with a list marker + */ +const startsWithListMarker = (text, isFirstLineOfTag = false) => { + // On the first line of a tag, the hyphen is typically the JSDoc separator, + // not a list marker + if (isFirstLineOfTag) { + return false; + } + + // Match lines that start with optional whitespace, then a list marker + // - or * followed by a space + // or a number followed by . or ) and a space + return /^\s*(?:[\-*]|\d+(?:\.|\)))\s+/v.test(text); +}; + +/** + * Checks if we should allow extra indentation beyond wrapIndent. + * This is true for list continuation lines (lines with more indent than wrapIndent + * that follow a list item). + * @param {import('comment-parser').Spec} tag - The tag being checked + * @param {import('../iterateJsdoc.js').Integer} idx - Current line index (0-based in tag.source.slice(1)) + * @returns {boolean} - True if extra indentation should be allowed + */ +const shouldAllowExtraIndent = (tag, idx) => { + // Check if any previous line in this tag had a list marker + // idx is 0-based in the continuation lines (tag.source.slice(1)) + // So tag.source[0] is the tag line, tag.source[idx+1] is current line + let hasSeenListMarker = false; + + // Check all lines from the tag line onwards + for (let lineIdx = 0; lineIdx <= idx + 1; lineIdx++) { + const line = tag.source[lineIdx]; + const isFirstLine = lineIdx === 0; + if (line?.tokens?.description && startsWithListMarker(line.tokens.description, isFirstLine)) { + hasSeenListMarker = true; + break; + } + } + + return hasSeenListMarker; +}; + /** * @typedef {{ * postDelimiter: import('../iterateJsdoc.js').Integer, @@ -298,7 +346,17 @@ export default iterateJsdoc(({ } // Don't include a single separating space/tab - if (!disableWrapIndent && tokens.postDelimiter.slice(1) !== wrapIndent) { + const actualIndent = tokens.postDelimiter.slice(1); + const hasCorrectWrapIndent = actualIndent === wrapIndent; + + // Allow extra indentation if this line or previous lines contain list markers + // This preserves nested list structure + const hasExtraIndent = actualIndent.length > wrapIndent.length && + actualIndent.startsWith(wrapIndent); + const isInListContext = shouldAllowExtraIndent(tag, idx - 1); + + if (!disableWrapIndent && !hasCorrectWrapIndent && + !(hasExtraIndent && isInListContext)) { utils.reportJSDoc('Expected wrap indent', { line: tag.source[0].number + idx, }, () => { diff --git a/test/rules/assertions/checkLineAlignment.js b/test/rules/assertions/checkLineAlignment.js index 2f91fed7a..621baccb8 100644 --- a/test/rules/assertions/checkLineAlignment.js +++ b/test/rules/assertions/checkLineAlignment.js @@ -2167,5 +2167,133 @@ export default /** @type {import('../index.js').TestCases} */ ({ }, ], }, + // List indentation tests - should preserve nested list structure + { + code: ` + /** + * @return {Promise} A promise. + * - On success, resolves. + * - On error, rejects with details: + * - When aborted, textStatus is "abort". + * - On timeout, textStatus is "timeout". + */ + function test() {} + `, + options: [ + 'never', + { + wrapIndent: ' ', + }, + ], + }, + { + code: ` + /** + * @param {string} lorem Description with list: + * - First item + * - Second item + * - Nested item + * - Another nested item + */ + function test() {} + `, + options: [ + 'never', + { + wrapIndent: ' ', + }, + ], + }, + { + code: ` + /** + * @return {Promise} A promise. + * 1. First step + * 2. Second step with continuation + * on another line + * 3. Third step + */ + function test() {} + `, + options: [ + 'never', + { + wrapIndent: ' ', + }, + ], + }, + { + code: ` + /** + * @param {Object} options Configuration options. + * * First option + * * Second option with details: + * * Nested detail + * * Another detail + */ + function test() {} + `, + options: [ + 'never', + { + wrapIndent: ' ', + }, + ], + }, + // Test cases for "always" mode with list indentation + { + code: ` + /** + * @param {string} param Description with list: + * - Item 1 + * - Nested item + */ + function test(param) {} + `, + options: [ + 'always', + { + wrapIndent: ' ', + }, + ], + }, + { + code: ` + /** + * Function description. + * + * @param {string} lorem Description. + * @param {int} sit Description with list: + * - First item + * - Second item + * - Nested item + */ + const fn = ( lorem, sit ) => {} + `, + options: [ + 'always', + { + wrapIndent: ' ', + }, + ], + }, + { + code: ` + /** + * @return {Promise} A promise. + * - On success, resolves. + * - On error, rejects with details: + * - When aborted, status is "abort". + * - On timeout, status is "timeout". + */ + function test() {} + `, + options: [ + 'always', + { + wrapIndent: ' ', + }, + ], + }, ], });