Skip to content

Commit 2702882

Browse files
authored
Add custom processor to lint JSDoc codeblocks (#1300)
1 parent c0ec440 commit 2702882

File tree

99 files changed

+2137
-365
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

99 files changed

+2137
-365
lines changed

.github/workflows/main.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,4 @@ jobs:
4242
node-version: 24
4343
- run: npm install
4444
- run: npm install typescript@${{ matrix.typescript-version }}
45-
- run: npx tsc
45+
- run: NODE_OPTIONS="--max-old-space-size=5120" npx tsc

.github/workflows/ts-canary.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,4 @@ jobs:
2323
- run: npm install typescript@${{ matrix.typescript-version }}
2424
- name: show installed typescript version
2525
run: npm list typescript --depth=0
26-
- run: npx tsc
26+
- run: NODE_OPTIONS="--max-old-space-size=5120" npx tsc
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import tseslint from 'typescript-eslint';
2+
import {defineConfig} from 'eslint/config';
3+
import {jsdocCodeblocksProcessor} from '../jsdoc-codeblocks.js';
4+
5+
const errorEndingAtFirstColumnRule = {
6+
create(context) {
7+
return {
8+
'TSTypeAliasDeclaration Literal'(node) {
9+
if (node.value !== 'error_ending_at_first_column') {
10+
return;
11+
}
12+
13+
context.report({
14+
loc: {
15+
start: {
16+
line: node.loc.start.line,
17+
column: 0,
18+
},
19+
end: {
20+
line: node.loc.start.line + 1,
21+
column: 0,
22+
},
23+
},
24+
message: 'Error ending at first column',
25+
});
26+
},
27+
};
28+
},
29+
};
30+
31+
const config = defineConfig(
32+
tseslint.configs.recommended,
33+
tseslint.configs.stylistic,
34+
{
35+
rules: {
36+
'@typescript-eslint/no-unused-vars': 'off',
37+
'@typescript-eslint/consistent-type-definitions': [
38+
'error',
39+
'type',
40+
],
41+
'test/error-ending-at-first-column': 'error',
42+
},
43+
},
44+
{
45+
plugins: {
46+
test: {
47+
processors: {
48+
'jsdoc-codeblocks': jsdocCodeblocksProcessor,
49+
},
50+
rules: {
51+
'error-ending-at-first-column': errorEndingAtFirstColumnRule,
52+
},
53+
},
54+
},
55+
},
56+
{
57+
files: ['**/*.d.ts'],
58+
processor: 'test/jsdoc-codeblocks',
59+
},
60+
);
61+
62+
export default config;
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
// @ts-check
2+
import tsParser from '@typescript-eslint/parser';
3+
4+
/**
5+
@import {Linter} from 'eslint';
6+
*/
7+
8+
const CODEBLOCK_REGEX = /(?<openingFence>(?<indent>^[ \t]*)```(?:ts|typescript)?\n)(?<code>[\s\S]*?)\n\s*```/gm;
9+
/**
10+
@typedef {{lineOffset: number, characterOffset: number, indent: string, unindentedText: string}} CodeblockData
11+
@type {Map<string, CodeblockData[]>}
12+
*/
13+
const codeblockDataPerFile = new Map();
14+
15+
/**
16+
@param {string} text
17+
@param {number} index
18+
@param {string} indent
19+
@returns {number}
20+
*/
21+
function indentsUptoIndex(text, index, indent) {
22+
let i = 0;
23+
let indents = 0;
24+
25+
for (const line of text.split('\n')) {
26+
if (i > index) {
27+
break;
28+
}
29+
30+
if (line === '') {
31+
i += 1; // +1 for the newline
32+
continue;
33+
}
34+
35+
i += line.length + 1; // +1 for the newline
36+
i -= indent.length; // Because `text` is unindented but `index` corresponds to dedented text
37+
indents += indent.length;
38+
}
39+
40+
return indents;
41+
}
42+
43+
export const jsdocCodeblocksProcessor = {
44+
supportsAutofix: true,
45+
46+
/**
47+
@param {string} text
48+
@param {string} filename
49+
@returns {(string | Linter.ProcessorFile)[]}
50+
*/
51+
preprocess(text, filename) {
52+
const ast = tsParser.parse(text);
53+
54+
const jsdocComments = ast.comments.filter(
55+
comment => comment.type === 'Block' && comment.value.startsWith('*'),
56+
);
57+
58+
/** @type {(string | Linter.ProcessorFile)[]} */
59+
const files = [text]; // First entry is for the entire file
60+
/** @type {CodeblockData[]} */
61+
const allCodeblocksData = [];
62+
63+
// Loop over all JSDoc comments in the file
64+
for (const comment of jsdocComments) {
65+
// Loop over all codeblocks in the JSDoc comment
66+
for (const match of comment.value.matchAll(CODEBLOCK_REGEX)) {
67+
const {code, openingFence, indent} = match.groups ?? {};
68+
69+
// Skip empty code blocks
70+
if (!code || !openingFence || indent === undefined) {
71+
continue;
72+
}
73+
74+
const codeLines = code.split('\n');
75+
const indentSize = indent.length;
76+
77+
// Skip comments that are not consistently indented
78+
if (!codeLines.every(line => line === '' || line.startsWith(indent))) {
79+
continue;
80+
}
81+
82+
const dedentedCode = codeLines
83+
.map(line => line.slice(indentSize))
84+
.join('\n');
85+
86+
files.push({
87+
text: dedentedCode,
88+
filename: `${files.length}.ts`, // Final filename example: `/path/to/type-fest/source/and.d.ts/1_1.ts`
89+
});
90+
91+
const linesBeforeMatch = comment.value.slice(0, match.index).split('\n').length - 1;
92+
allCodeblocksData.push({
93+
lineOffset: comment.loc.start.line + linesBeforeMatch,
94+
characterOffset: comment.range[0] + match.index + openingFence.length + 2, // +2 because `comment.value` doesn't include the starting `/*`
95+
indent,
96+
unindentedText: code,
97+
});
98+
}
99+
}
100+
101+
codeblockDataPerFile.set(filename, allCodeblocksData);
102+
103+
return files;
104+
},
105+
106+
/**
107+
@param {import('eslint').Linter.LintMessage[][]} messages
108+
@param {string} filename
109+
@returns {import('eslint').Linter.LintMessage[]}
110+
*/
111+
postprocess(messages, filename) {
112+
const codeblocks = codeblockDataPerFile.get(filename) || [];
113+
codeblockDataPerFile.delete(filename);
114+
115+
const normalizedMessages = [...(messages[0] ?? [])]; // First entry contains errors for the entire file, and it doesn't need any adjustments
116+
117+
for (const [index, codeblockMessages] of messages.slice(1).entries()) {
118+
const codeblockData = codeblocks[index];
119+
120+
if (!codeblockData) {
121+
// This should ideally never happen
122+
continue;
123+
}
124+
125+
const {lineOffset, characterOffset, indent, unindentedText} = codeblockData;
126+
127+
for (const message of codeblockMessages) {
128+
message.line += lineOffset;
129+
message.column += indent.length;
130+
131+
if (typeof message.endColumn === 'number' && message.endColumn > 1) {
132+
// An `endColumn` of `1` indicates the error actually ended on the previous line since it's exclusive.
133+
// So, adding `indent.length` in this case would incorrectly move the error marker into the indentation.
134+
// Therefore, the indentation length is only added when `endColumn` is greater than `1`.
135+
message.endColumn += indent.length;
136+
}
137+
138+
if (typeof message.endLine === 'number') {
139+
message.endLine += lineOffset;
140+
}
141+
142+
if (message.fix) {
143+
message.fix.text = message.fix.text.split('\n').join(`\n${indent}`);
144+
145+
const indentsBeforeFixStart = indentsUptoIndex(unindentedText, message.fix.range[0], indent);
146+
const indentsBeforeFixEnd = indentsUptoIndex(unindentedText, message.fix.range[1] - 1, indent); // -1 because range end is exclusive
147+
148+
message.fix.range = [
149+
message.fix.range[0] + characterOffset + indentsBeforeFixStart,
150+
message.fix.range[1] + characterOffset + indentsBeforeFixEnd,
151+
];
152+
}
153+
154+
for (const {fix} of (message.suggestions ?? [])) {
155+
fix.text = fix.text.split('\n').join(`\n${indent}`);
156+
157+
const indentsBeforeFixStart = indentsUptoIndex(unindentedText, fix.range[0], indent);
158+
const indentsBeforeFixEnd = indentsUptoIndex(unindentedText, fix.range[1] - 1, indent); // -1 because range end is exclusive
159+
160+
fix.range = [
161+
fix.range[0] + characterOffset + indentsBeforeFixStart,
162+
fix.range[1] + characterOffset + indentsBeforeFixEnd,
163+
];
164+
}
165+
166+
normalizedMessages.push(message);
167+
}
168+
}
169+
170+
return normalizedMessages;
171+
},
172+
};

0 commit comments

Comments
 (0)