Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions docs/rules/check-line-alignment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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":" "}]
````

88 changes: 87 additions & 1 deletion src/alignTransform.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,27 @@
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,
Expand Down Expand Up @@ -144,6 +165,59 @@
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,
Expand Down Expand Up @@ -216,7 +290,7 @@
}
}

// Todo: Avoid fixing alignment of blocks with multiline wrapping of type

Check warning on line 293 in src/alignTransform.js

View workflow job for this annotation

GitHub Actions / Lint

Unexpected 'todo' comment: 'Todo: Avoid fixing alignment of blocks...'

Check warning on line 293 in src/alignTransform.js

View workflow job for this annotation

GitHub Actions / Lint

Unexpected 'todo' comment: 'Todo: Avoid fixing alignment of blocks...'

Check warning on line 293 in src/alignTransform.js

View workflow job for this annotation

GitHub Actions / Lint

Unexpected 'todo' comment: 'Todo: Avoid fixing alignment of blocks...'
if (tokens.tag === '' && tokens.type) {
return tokens;
}
Expand Down Expand Up @@ -316,8 +390,20 @@
// 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;
}
}
}

Expand Down
60 changes: 59 additions & 1 deletion src/rules/checkLineAlignment.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
}, () => {
Expand Down
128 changes: 128 additions & 0 deletions test/rules/assertions/checkLineAlignment.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: ' ',
},
],
},
],
});
Loading