From 5c5799a84ba08b272b80b22f96996508113890f4 Mon Sep 17 00:00:00 2001 From: Brett Zamir Date: Wed, 19 Nov 2025 05:02:40 -0700 Subject: [PATCH 1/2] feat: `allowIndentedSections` option; fixes #541 --- .README/rules/check-indentation.md | 2 +- docs/rules/check-indentation.md | 51 +++++++++- src/rules.d.ts | 4 + src/rules/checkIndentation.js | 68 +++++++++++-- test/rules/assertions/checkIndentation.js | 114 ++++++++++++++++++++++ 5 files changed, 230 insertions(+), 9 deletions(-) diff --git a/.README/rules/check-indentation.md b/.README/rules/check-indentation.md index 805cc5f04..9e0c4777e 100644 --- a/.README/rules/check-indentation.md +++ b/.README/rules/check-indentation.md @@ -29,7 +29,7 @@ the following description is not reported: |Context|everywhere| |Tags|N/A| |Recommended|false| -|Options|`excludeTags`| +|Options|`allowIndentedSections`, `excludeTags`| ## Failing examples diff --git a/docs/rules/check-indentation.md b/docs/rules/check-indentation.md index a692d8a38..e44e0ebd0 100644 --- a/docs/rules/check-indentation.md +++ b/docs/rules/check-indentation.md @@ -3,6 +3,7 @@ # check-indentation * [Options](#user-content-check-indentation-options) + * [`allowIndentedSections`](#user-content-check-indentation-options-allowindentedsections) * [`excludeTags`](#user-content-check-indentation-options-excludetags) * [Context and settings](#user-content-check-indentation-context-and-settings) * [Failing examples](#user-content-check-indentation-failing-examples) @@ -31,6 +32,12 @@ the following description is not reported: A single options object has the following properties. + + +### allowIndentedSections + +Allows indentation of nested sections on subsequent lines (like bullet lists) + ### excludeTags @@ -65,7 +72,7 @@ report a padding issue: |Context|everywhere| |Tags|N/A| |Recommended|false| -|Options|`excludeTags`| +|Options|`allowIndentedSections`, `excludeTags`| @@ -179,6 +186,19 @@ function quux () { */ // "jsdoc/check-indentation": ["error"|"warn", {"excludeTags":[]}] // Message: There must be no indentation. + +/** + * @param {number} val Still disallowed + */ +// "jsdoc/check-indentation": ["error"|"warn", {"allowIndentedSections":true}] +// Message: There must be no indentation. + +/** + * Disallowed + * Indentation + */ +// "jsdoc/check-indentation": ["error"|"warn", {"allowIndentedSections":true}] +// Message: There must be no indentation. ```` @@ -293,5 +313,34 @@ function MyDecorator(options: { myOptions: number }) { function MyDecorator(options: { myOptions: number }) { return (Base: Function) => {}; } + +/** + * Foobar + * + * This method does the following things: + * - foo... + * this is the first step + * - bar + * this is the second step + */ +// "jsdoc/check-indentation": ["error"|"warn", {"allowIndentedSections":true}] + +/** + * Allowed + * Indentation + */ +// "jsdoc/check-indentation": ["error"|"warn", {"allowIndentedSections":true}] + +/** + * @param {number} val Multi- + * line + */ +// "jsdoc/check-indentation": ["error"|"warn", {"allowIndentedSections":true}] + +/** + * - foo: + * - bar + */ +// "jsdoc/check-indentation": ["error"|"warn", {"allowIndentedSections":true}] ```` diff --git a/src/rules.d.ts b/src/rules.d.ts index 7a280390e..3ee700908 100644 --- a/src/rules.d.ts +++ b/src/rules.d.ts @@ -47,6 +47,10 @@ export interface Rules { | [] | [ { + /** + * Allows indentation of nested sections on subsequent lines (like bullet lists) + */ + allowIndentedSections?: boolean; /** * Array of tags (e.g., `['example', 'description']`) whose content will be * "hidden" from the `check-indentation` rule. Defaults to `['example']`. diff --git a/src/rules/checkIndentation.js b/src/rules/checkIndentation.js index d8bb80f68..d246a8d0e 100644 --- a/src/rules/checkIndentation.js +++ b/src/rules/checkIndentation.js @@ -32,21 +32,71 @@ export default iterateJsdoc(({ sourceCode, }) => { const options = context.options[0] || {}; - const /** @type {{excludeTags: string[]}} */ { + const /** @type {{excludeTags: string[], allowIndentedSections: boolean}} */ { + allowIndentedSections = false, excludeTags = [ 'example', ], } = options; - const reg = /^(?:\/?\**|[ \t]*)\*[ \t]{2}/gmv; const textWithoutCodeBlocks = maskCodeBlocks(sourceCode.getText(jsdocNode)); const text = excludeTags.length ? maskExcludedContent(textWithoutCodeBlocks, excludeTags) : textWithoutCodeBlocks; - if (reg.test(text)) { - const lineBreaks = text.slice(0, reg.lastIndex).match(/\n/gv) || []; - report('There must be no indentation.', null, { - line: lineBreaks.length, - }); + if (allowIndentedSections) { + // When allowIndentedSections is enabled, only check for indentation on tag lines + // and the very first line of the main description + const lines = text.split('\n'); + let hasSeenContent = false; + + for (const [ + lineIndex, + line, + ] of lines.entries()) { + // Check for indentation (two or more spaces after *) + const indentMatch = line.match(/^(?:\/?\**|[\t ]*)\*([\t ]{2,})/gv); + + if (indentMatch) { + // Check what comes after the indentation + const afterIndent = line.slice(indentMatch[0].length); + + // If this is a tag line with indentation, always report + if (/^@\w+/v.test(afterIndent)) { + // Count newlines before this line + const precedingText = lines.slice(0, lineIndex).join('\n'); + const lineBreaks = precedingText.match(/\n/gv) || []; + report('There must be no indentation.', null, { + line: lineBreaks.length + 1, + }); + return; + } + + // If we haven't seen any content yet (main description first line) and there's content, report + if (!hasSeenContent && afterIndent.trim().length > 0) { + // Count newlines before this line + const precedingText = lines.slice(0, lineIndex).join('\n'); + const lineBreaks = precedingText.match(/\n/gv) || []; + report('There must be no indentation.', null, { + line: lineBreaks.length + 1, + }); + return; + } + + // Otherwise, allow it (continuation lines) + } + + // Track if we've seen any content (non-whitespace after the *) + if (/^\s*\*\s+\S/v.test(line)) { + hasSeenContent = true; + } + } + } else { + const reg = /^(?:\/?\**|[ \t]*)\*[ \t]{2}/gmv; + if (reg.test(text)) { + const lineBreaks = text.slice(0, reg.lastIndex).match(/\n/gv) || []; + report('There must be no indentation.', null, { + line: lineBreaks.length, + }); + } } }, { iterateAllJsdocs: true, @@ -59,6 +109,10 @@ export default iterateJsdoc(({ { additionalProperties: false, properties: { + allowIndentedSections: { + description: 'Allows indentation of nested sections on subsequent lines (like bullet lists)', + type: 'boolean', + }, excludeTags: { description: `Array of tags (e.g., \`['example', 'description']\`) whose content will be "hidden" from the \`check-indentation\` rule. Defaults to \`['example']\`. diff --git a/test/rules/assertions/checkIndentation.js b/test/rules/assertions/checkIndentation.js index 90c8cc8ce..b51952c4d 100644 --- a/test/rules/assertions/checkIndentation.js +++ b/test/rules/assertions/checkIndentation.js @@ -194,6 +194,63 @@ export default /** @type {import('../index.js').TestCases} */ ({ }, ], }, + { + code: ` + /** + * @param {number} val Still disallowed + */ + `, + errors: [ + { + line: 3, + message: 'There must be no indentation.', + }, + ], + options: [ + { + allowIndentedSections: true, + }, + ], + }, + { + code: ` + /** + * Disallowed + * Indentation + */ + `, + errors: [ + { + line: 3, + message: 'There must be no indentation.', + }, + ], + options: [ + { + allowIndentedSections: true, + }, + ], + }, + { + code: ` + /** + * Some text + * that is indented + * but is inconsistent + */ + `, + errors: [ + { + line: 5, + message: 'There must be no indentation.', + }, + ], + options: [ + { + allowIndentedSections: true, + }, + ], + }, ], valid: [ { @@ -349,5 +406,62 @@ export default /** @type {import('../index.js').TestCases} */ ({ parser: typescriptEslintParser, }, }, + { + code: ` + /** + * Foobar + * + * This method does the following things: + * - foo... + * this is the first step + * - bar + * this is the second step + */ + `, + options: [ + { + allowIndentedSections: true, + }, + ], + }, + { + code: ` + /** + * Allowed + * Indentation + */ + `, + options: [ + { + allowIndentedSections: true, + }, + ], + }, + { + code: ` + /** + * @param {number} val Multi- + * line + */ + `, + options: [ + { + allowIndentedSections: true, + }, + ], + }, + { + code: ` + /** + * - foo: + * - bar + */ + `, + options: [ + { + allowIndentedSections: true, + }, + ], + }, ], }); From 4da1f3ed2b75512b86ba7c1b7a8bcf985d0318bd Mon Sep 17 00:00:00 2001 From: Brett Zamir Date: Wed, 19 Nov 2025 05:21:31 -0700 Subject: [PATCH 2/2] refactor: forbid inconsistent indentation with new option --- docs/rules/check-indentation.md | 36 +++++++++++++ src/rules/checkIndentation.js | 48 ++++++++++++++---- test/rules/assertions/checkIndentation.js | 61 +++++++++++++++++++++++ 3 files changed, 135 insertions(+), 10 deletions(-) diff --git a/docs/rules/check-indentation.md b/docs/rules/check-indentation.md index e44e0ebd0..a72efc131 100644 --- a/docs/rules/check-indentation.md +++ b/docs/rules/check-indentation.md @@ -199,6 +199,19 @@ function quux () { */ // "jsdoc/check-indentation": ["error"|"warn", {"allowIndentedSections":true}] // Message: There must be no indentation. + +/** + * Some text + * that is indented + * but is inconsistent + */ +// "jsdoc/check-indentation": ["error"|"warn", {"allowIndentedSections":true}] +// Message: There must be no indentation. + +/** Indented on first line + */ +// "jsdoc/check-indentation": ["error"|"warn", {"allowIndentedSections":true}] +// Message: There must be no indentation. ```` @@ -342,5 +355,28 @@ function MyDecorator(options: { myOptions: number }) { * - bar */ // "jsdoc/check-indentation": ["error"|"warn", {"allowIndentedSections":true}] + +/** + * Some text + * that is indented + * and continues at same level + * and increases further + */ +// "jsdoc/check-indentation": ["error"|"warn", {"allowIndentedSections":true}] + +/** + * Description + * @param {string} foo Param + * with continuation + * at same indentation + */ +// "jsdoc/check-indentation": ["error"|"warn", {"allowIndentedSections":true}] + +/** + * Description + * + * More content + */ +// "jsdoc/check-indentation": ["error"|"warn", {"allowIndentedSections":true}] ```` diff --git a/src/rules/checkIndentation.js b/src/rules/checkIndentation.js index d246a8d0e..6e6f2b9bc 100644 --- a/src/rules/checkIndentation.js +++ b/src/rules/checkIndentation.js @@ -25,6 +25,17 @@ const maskCodeBlocks = (str) => { }); }; +/** + * @param {string[]} lines + * @param {number} lineIndex + * @returns {number} + */ +const getLineNumber = (lines, lineIndex) => { + const precedingText = lines.slice(0, lineIndex).join('\n'); + const lineBreaks = precedingText.match(/\n/gv) || []; + return lineBreaks.length + 1; +}; + export default iterateJsdoc(({ context, jsdocNode, @@ -47,47 +58,64 @@ export default iterateJsdoc(({ // and the very first line of the main description const lines = text.split('\n'); let hasSeenContent = false; + let currentSectionIndent = null; for (const [ lineIndex, line, ] of lines.entries()) { // Check for indentation (two or more spaces after *) - const indentMatch = line.match(/^(?:\/?\**|[\t ]*)\*([\t ]{2,})/gv); + const indentMatch = line.match(/^(?:\/?\**|[\t ]*)\*([\t ]{2,})/v); if (indentMatch) { // Check what comes after the indentation const afterIndent = line.slice(indentMatch[0].length); + const indentAmount = indentMatch[1].length; // If this is a tag line with indentation, always report if (/^@\w+/v.test(afterIndent)) { - // Count newlines before this line - const precedingText = lines.slice(0, lineIndex).join('\n'); - const lineBreaks = precedingText.match(/\n/gv) || []; report('There must be no indentation.', null, { - line: lineBreaks.length + 1, + line: getLineNumber(lines, lineIndex), }); return; } // If we haven't seen any content yet (main description first line) and there's content, report if (!hasSeenContent && afterIndent.trim().length > 0) { - // Count newlines before this line - const precedingText = lines.slice(0, lineIndex).join('\n'); - const lineBreaks = precedingText.match(/\n/gv) || []; report('There must be no indentation.', null, { - line: lineBreaks.length + 1, + line: getLineNumber(lines, lineIndex), }); return; } - // Otherwise, allow it (continuation lines) + // For continuation lines, check consistency + if (hasSeenContent && afterIndent.trim().length > 0) { + if (currentSectionIndent === null) { + // First indented line in this section, set the indent level + currentSectionIndent = indentAmount; + } else if (indentAmount < currentSectionIndent) { + // Indentation is less than the established level (inconsistent) + report('There must be no indentation.', null, { + line: getLineNumber(lines, lineIndex), + }); + return; + } + } + } else if (/^\s*\*\s+\S/v.test(line)) { + // No indentation on this line, reset section indent tracking + // (unless it's just whitespace or a closing comment) + currentSectionIndent = null; } // Track if we've seen any content (non-whitespace after the *) if (/^\s*\*\s+\S/v.test(line)) { hasSeenContent = true; } + + // Reset section indent when we encounter a tag + if (/@\w+/v.test(line)) { + currentSectionIndent = null; + } } } else { const reg = /^(?:\/?\**|[ \t]*)\*[ \t]{2}/gmv; diff --git a/test/rules/assertions/checkIndentation.js b/test/rules/assertions/checkIndentation.js index b51952c4d..7521bafc4 100644 --- a/test/rules/assertions/checkIndentation.js +++ b/test/rules/assertions/checkIndentation.js @@ -251,6 +251,23 @@ export default /** @type {import('../index.js').TestCases} */ ({ }, ], }, + { + code: ` + /** Indented on first line + */ + `, + errors: [ + { + line: 3, + message: 'There must be no indentation.', + }, + ], + options: [ + { + allowIndentedSections: true, + }, + ], + }, ], valid: [ { @@ -463,5 +480,49 @@ export default /** @type {import('../index.js').TestCases} */ ({ }, ], }, + { + code: ` + /** + * Some text + * that is indented + * and continues at same level + * and increases further + */ + `, + options: [ + { + allowIndentedSections: true, + }, + ], + }, + { + code: ` + /** + * Description + * @param {string} foo Param + * with continuation + * at same indentation + */ + `, + options: [ + { + allowIndentedSections: true, + }, + ], + }, + { + code: ` + /** + * Description + * + * More content + */ + `, + options: [ + { + allowIndentedSections: true, + }, + ], + }, ], });