Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
bb5f895
feat: add custom processor to lint JSDoc codeblocks
som-sm Nov 19, 2025
9acd9b2
feat: handle indented JSDoc comments
som-sm Nov 19, 2025
f5eba7f
refactor: improve indented JSDoc codeblocks handling
som-sm Nov 19, 2025
1146158
fix: lint issues in JSDoc codeblocks
som-sm Nov 19, 2025
523017c
fix: non auto-fixable lint issues in JSDoc codeblocks
som-sm Nov 20, 2025
30cb611
fix: incorrect conversion of `interface` to `type`
som-sm Nov 20, 2025
b7dea22
fix: adjust fixer positioning for editor suggestions
som-sm Nov 20, 2025
b7695d7
fix: disable `allowJs`
som-sm Nov 20, 2025
150f369
fix: incorrect newlines in `indent` capturing group
som-sm Nov 20, 2025
61ef5f3
chore: enable back `allowJs`
som-sm Nov 20, 2025
55a48cc
fix: fixer position adjustment for indented codeblocks
som-sm Nov 21, 2025
3f4d9d7
fix: remove unnecessary check
som-sm Nov 21, 2025
c4290b3
chore: add comment
som-sm Nov 23, 2025
004e742
fix: indent calculation when `index` is `0`
som-sm Nov 25, 2025
79961bf
test: setup tests for `jsdoc-codeblocks` processor
som-sm Nov 24, 2025
053b6cb
refactor: replace `assert.partialDeepStrictEqual` with manual check
som-sm Nov 25, 2025
e968878
refactor: cleanup code
som-sm Nov 25, 2025
73428af
fix: remove spaces, use tabs
som-sm Nov 26, 2025
a255bf7
test: refactor assertion so that error messages are more readable
som-sm Nov 26, 2025
7000982
test: add more cases
som-sm Nov 26, 2025
290f14e
test: improve test cases
som-sm Nov 26, 2025
6bdd968
test: add valid cases
som-sm Nov 26, 2025
2c50aff
test: add more cases
som-sm Nov 27, 2025
927cbbf
test: add case covering errors ending at first column
som-sm Nov 27, 2025
a47aecd
refactor: improve types and naming
som-sm Nov 27, 2025
da3bd0d
refactor: improve code
som-sm Nov 27, 2025
e9440ac
refactor: move code samples to utils
som-sm Nov 27, 2025
0820fe5
test: improve test cases
som-sm Nov 27, 2025
d7a0f1c
chore: merge branch 'main'
som-sm Nov 27, 2025
5c94733
fix: update `tsc` command in `ts-canary` workflow
som-sm Nov 27, 2025
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
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,4 @@ jobs:
node-version: 24
- run: npm install
- run: npm install typescript@${{ matrix.typescript-version }}
- run: npx tsc
- run: NODE_OPTIONS="--max-old-space-size=5120" npx tsc
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This bump is needed because the number of files in the TS program has increased from 792 to 839. The default limit seems to be 4GB, and increasing it by 1GB works fine.

The extra files come from the new typescript-eslint package added in this PR.

# main
npx tsc --listFilesOnly | wc -l # 792
# feat/lint-jsdoc-codeblocks
npx tsc --listFilesOnly | wc -l # 839

If this is a problem, we can disable allowJS in tsconfig.json for now, which will bring down the files to 514 and then come back to this separately.

# "allowJS": false
npx tsc --listFilesOnly | wc -l # 514

2 changes: 1 addition & 1 deletion .github/workflows/ts-canary.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,4 @@ jobs:
- run: npm install typescript@${{ matrix.typescript-version }}
- name: show installed typescript version
run: npm list typescript --depth=0
- run: npx tsc
- run: NODE_OPTIONS="--max-old-space-size=5120" npx tsc
62 changes: 62 additions & 0 deletions lint-processors/fixtures/eslint.config.js
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This config file is used to lint the test cases. And, this is present inside the repo instead of the temp directory because it needs certain imports.

Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import tseslint from 'typescript-eslint';
import {defineConfig} from 'eslint/config';
import {jsdocCodeblocksProcessor} from '../jsdoc-codeblocks.js';

const errorEndingAtFirstColumnRule = {
create(context) {
return {
'TSTypeAliasDeclaration Literal'(node) {
if (node.value !== 'error_ending_at_first_column') {
return;
}

context.report({
loc: {
start: {
line: node.loc.start.line,
column: 0,
},
end: {
line: node.loc.start.line + 1,
column: 0,
},
},
message: 'Error ending at first column',
});
},
};
},
};

const config = defineConfig(
tseslint.configs.recommended,
tseslint.configs.stylistic,
{
rules: {
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/consistent-type-definitions': [
'error',
'type',
],
'test/error-ending-at-first-column': 'error',
},
},
{
plugins: {
test: {
processors: {
'jsdoc-codeblocks': jsdocCodeblocksProcessor,
},
rules: {
'error-ending-at-first-column': errorEndingAtFirstColumnRule,
},
},
},
},
{
files: ['**/*.d.ts'],
processor: 'test/jsdoc-codeblocks',
},
);

export default config;
172 changes: 172 additions & 0 deletions lint-processors/jsdoc-codeblocks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// @ts-check
import tsParser from '@typescript-eslint/parser';

/**
@import {Linter} from 'eslint';
*/

const CODEBLOCK_REGEX = /(?<openingFence>(?<indent>^[ \t]*)```(?:ts|typescript)?\n)(?<code>[\s\S]*?)\n\s*```/gm;
/**
@typedef {{lineOffset: number, characterOffset: number, indent: string, unindentedText: string}} CodeblockData
@type {Map<string, CodeblockData[]>}
*/
const codeblockDataPerFile = new Map();

/**
@param {string} text
@param {number} index
@param {string} indent
@returns {number}
*/
function indentsUptoIndex(text, index, indent) {
let i = 0;
let indents = 0;

for (const line of text.split('\n')) {
if (i > index) {
break;
}

if (line === '') {
i += 1; // +1 for the newline
continue;
}

i += line.length + 1; // +1 for the newline
i -= indent.length; // Because `text` is unindented but `index` corresponds to dedented text
indents += indent.length;
}

return indents;
}

export const jsdocCodeblocksProcessor = {
supportsAutofix: true,

/**
@param {string} text
@param {string} filename
@returns {(string | Linter.ProcessorFile)[]}
*/
preprocess(text, filename) {
const ast = tsParser.parse(text);

const jsdocComments = ast.comments.filter(
comment => comment.type === 'Block' && comment.value.startsWith('*'),
);

/** @type {(string | Linter.ProcessorFile)[]} */
const files = [text]; // First entry is for the entire file
/** @type {CodeblockData[]} */
const allCodeblocksData = [];

// Loop over all JSDoc comments in the file
for (const comment of jsdocComments) {
// Loop over all codeblocks in the JSDoc comment
for (const match of comment.value.matchAll(CODEBLOCK_REGEX)) {
const {code, openingFence, indent} = match.groups ?? {};

// Skip empty code blocks
if (!code || !openingFence || indent === undefined) {
continue;
}

const codeLines = code.split('\n');
const indentSize = indent.length;

// Skip comments that are not consistently indented
if (!codeLines.every(line => line === '' || line.startsWith(indent))) {
continue;
}

const dedentedCode = codeLines
.map(line => line.slice(indentSize))
.join('\n');

files.push({
text: dedentedCode,
filename: `${files.length}.ts`, // Final filename example: `/path/to/type-fest/source/and.d.ts/1_1.ts`
});

const linesBeforeMatch = comment.value.slice(0, match.index).split('\n').length - 1;
allCodeblocksData.push({
lineOffset: comment.loc.start.line + linesBeforeMatch,
characterOffset: comment.range[0] + match.index + openingFence.length + 2, // +2 because `comment.value` doesn't include the starting `/*`
indent,
unindentedText: code,
});
}
}

codeblockDataPerFile.set(filename, allCodeblocksData);

return files;
},

/**
@param {import('eslint').Linter.LintMessage[][]} messages
@param {string} filename
@returns {import('eslint').Linter.LintMessage[]}
*/
postprocess(messages, filename) {
const codeblocks = codeblockDataPerFile.get(filename) || [];
codeblockDataPerFile.delete(filename);

const normalizedMessages = [...(messages[0] ?? [])]; // First entry contains errors for the entire file, and it doesn't need any adjustments

for (const [index, codeblockMessages] of messages.slice(1).entries()) {
const codeblockData = codeblocks[index];

if (!codeblockData) {
// This should ideally never happen
continue;
}

const {lineOffset, characterOffset, indent, unindentedText} = codeblockData;

for (const message of codeblockMessages) {
message.line += lineOffset;
message.column += indent.length;

if (typeof message.endColumn === 'number' && message.endColumn > 1) {
// An `endColumn` of `1` indicates the error actually ended on the previous line since it's exclusive.
// So, adding `indent.length` in this case would incorrectly move the error marker into the indentation.
// Therefore, the indentation length is only added when `endColumn` is greater than `1`.
message.endColumn += indent.length;
}

if (typeof message.endLine === 'number') {
message.endLine += lineOffset;
}

if (message.fix) {
message.fix.text = message.fix.text.split('\n').join(`\n${indent}`);

const indentsBeforeFixStart = indentsUptoIndex(unindentedText, message.fix.range[0], indent);
const indentsBeforeFixEnd = indentsUptoIndex(unindentedText, message.fix.range[1] - 1, indent); // -1 because range end is exclusive

message.fix.range = [
message.fix.range[0] + characterOffset + indentsBeforeFixStart,
message.fix.range[1] + characterOffset + indentsBeforeFixEnd,
];
}

for (const {fix} of (message.suggestions ?? [])) {
fix.text = fix.text.split('\n').join(`\n${indent}`);

const indentsBeforeFixStart = indentsUptoIndex(unindentedText, fix.range[0], indent);
const indentsBeforeFixEnd = indentsUptoIndex(unindentedText, fix.range[1] - 1, indent); // -1 because range end is exclusive

fix.range = [
fix.range[0] + characterOffset + indentsBeforeFixStart,
fix.range[1] + characterOffset + indentsBeforeFixEnd,
];
}

normalizedMessages.push(message);
}
}

return normalizedMessages;
},
};
Loading