Skip to content

Commit 2300245

Browse files
authored
Add lint rule to validate JSDoc codeblocks using TS compiler (#1265)
1 parent f14a75a commit 2300245

File tree

82 files changed

+1389
-401
lines changed

Some content is hidden

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

82 files changed

+1389
-401
lines changed

lint-rules/test-utils.js

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import os from 'node:os';
33
import path from 'node:path';
44
import {RuleTester} from 'eslint';
55
import tsParser from '@typescript-eslint/parser';
6+
import dedent from 'dedent';
67

78
export const createRuleTester = (overrides = {}) => {
89
const {
@@ -98,3 +99,168 @@ export const createTypeAwareRuleTester = (fixtureFiles, options = {}) => {
9899
writeFixture,
99100
};
100101
};
102+
103+
export const dedenter = dedent.withOptions({alignValues: true});
104+
105+
/**
106+
Returns the specified code in a fenced code block, with an optional language tag.
107+
108+
@example
109+
```
110+
fence('type A = string;');
111+
// Returns:
112+
// ```
113+
// type A = string;
114+
// ```
115+
116+
fence(`import {RemovePrefix} from 'type-fest';
117+
118+
type A = RemovePrefix<'onChange', 'on'>;
119+
//=> 'Change'`, 'ts');
120+
// Returns:
121+
// ```ts
122+
// import {RemovePrefix} from 'type-fest';
123+
124+
// type A = RemovePrefix<'onChange', 'on'>;
125+
// //=> 'Change'
126+
// ```
127+
```
128+
*/
129+
export const fence = (code, lang = '') =>
130+
dedenter`
131+
\`\`\`${lang}
132+
${code}
133+
\`\`\`
134+
`;
135+
136+
/**
137+
Returns the specified lines as a JSDoc comment, placing each specified line on a new line.
138+
139+
@example
140+
```
141+
jsdoc('Some description.', 'Note: Some note.');
142+
// Returns:
143+
// /**
144+
// Some description.
145+
// Note: Some note.
146+
// *​/
147+
148+
jsdoc('@example', '```\ntype A = string;\n```', '@category Test');
149+
// Returns;
150+
// /**
151+
// @example
152+
// ```
153+
// type A = string;
154+
// ```
155+
// @category Test
156+
// *​/
157+
```
158+
*/
159+
export const jsdoc = (...lines) =>
160+
dedenter`
161+
/**
162+
${lines.join('\n')}
163+
*/
164+
`;
165+
166+
/**
167+
Returns an exported type for each provided prefix, with each prefix placed directly above its corresponding type declaration.
168+
169+
@example
170+
```
171+
exportType(
172+
'// Some comment',
173+
'type Test = string;',
174+
'/**\nSome description.\nNote: Some note.\n*​/'
175+
);
176+
// Returns:
177+
// // Some comment
178+
// export type T0 = string;
179+
//
180+
// type Test = string;
181+
// export type T1 = string;
182+
//
183+
// /**
184+
// Some description.
185+
// Note: Some note.
186+
// *​/
187+
// export type T2 = string;
188+
*/
189+
export const exportType = (...prefixes) =>
190+
prefixes
191+
.map((doc, i) => dedenter`
192+
${doc}
193+
export type T${i} = string;
194+
`)
195+
.join('\n\n');
196+
197+
/**
198+
Returns an exported "Options" object type containing a property for each specified prefix, with each prefix placed directly above its corresponding property declaration.
199+
200+
@example
201+
```
202+
exportOption(
203+
'// Some comment',
204+
'type Test = string;',
205+
'/**\nSome description.\nNote: Some note.\n*​/'
206+
);
207+
// Returns:
208+
// export type TOptions = {
209+
// // Some comment
210+
// p0: string;
211+
212+
// test: string;
213+
// p1: string;
214+
215+
// /**
216+
// Some description.
217+
// Note: Some note.
218+
// *​/
219+
// p2: string;
220+
// };
221+
```
222+
*/
223+
export const exportOption = (...prefixes) =>
224+
dedenter`
225+
export type TOptions = {
226+
${prefixes
227+
.map((doc, i) => dedenter`
228+
${doc}
229+
p${i}: string;
230+
`)
231+
.join('\n\n')}
232+
};
233+
`;
234+
235+
/**
236+
Returns an exported type for each provided prefix, and an exported "Options" object type containing a property for each specified prefix, with each prefix placed directly above its corresponding declaration.
237+
238+
@example
239+
```
240+
exportTypeAndOption('// Some comment', '/**\nSome JSDoc\n*​/');
241+
// Returns:
242+
// // Some comment
243+
// type T0 = string;
244+
245+
// /**
246+
// Some JSDoc
247+
// *​/
248+
// type T1 = string;
249+
250+
// type TOptions = {
251+
// // Some comment
252+
// p0: string;
253+
254+
// /**
255+
// Some JSDoc
256+
// *​/
257+
// p1: string;
258+
// };
259+
```
260+
*/
261+
export const exportTypeAndOption = (...prefixes) =>
262+
dedenter`
263+
${exportType(...prefixes)}
264+
265+
${exportOption(...prefixes)}
266+
`;
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import path from 'node:path';
2+
import ts from 'typescript';
3+
import {createFSBackedSystem, createVirtualTypeScriptEnvironment} from '@typescript/vfs';
4+
5+
const CODEBLOCK_REGEX = /(?<openingFence>```(?:ts|typescript)?\n)(?<code>[\s\S]*?)```/g;
6+
const FILENAME = 'example-codeblock.ts';
7+
8+
const compilerOptions = {
9+
lib: ['lib.es2023.d.ts', 'lib.dom.d.ts', 'lib.dom.iterable.d.ts'],
10+
target: ts.ScriptTarget.ESNext,
11+
module: ts.ModuleKind.Node20,
12+
moduleResolution: ts.ModuleResolutionKind.Node16,
13+
strict: true,
14+
noImplicitReturns: true,
15+
noImplicitOverride: true,
16+
noUnusedLocals: false, // This is intentionally disabled
17+
noUnusedParameters: true,
18+
noFallthroughCasesInSwitch: true,
19+
noUncheckedIndexedAccess: true,
20+
noPropertyAccessFromIndexSignature: true,
21+
noUncheckedSideEffectImports: true,
22+
useDefineForClassFields: true,
23+
exactOptionalPropertyTypes: true,
24+
};
25+
26+
const virtualFsMap = new Map();
27+
virtualFsMap.set(FILENAME, '// Can\'t be empty');
28+
29+
const rootDir = path.join(import.meta.dirname, '..');
30+
const system = createFSBackedSystem(virtualFsMap, rootDir, ts);
31+
const env = createVirtualTypeScriptEnvironment(system, [FILENAME], ts, compilerOptions);
32+
33+
export const validateJSDocCodeblocksRule = /** @type {const} */ ({
34+
meta: {
35+
type: 'suggestion',
36+
docs: {
37+
description: 'Ensures JSDoc example codeblocks don\'t have errors',
38+
},
39+
messages: {
40+
invalidCodeblock: '{{errorMessage}}',
41+
},
42+
schema: [],
43+
},
44+
defaultOptions: [],
45+
create(context) {
46+
const filename = context.filename.replaceAll('\\', '/');
47+
48+
// Skip internal files
49+
if (filename.includes('/internal/')) {
50+
return {};
51+
}
52+
53+
return {
54+
TSTypeAliasDeclaration(node) {
55+
const {parent} = node;
56+
57+
// Skip if type is not exported or starts with an underscore (private/internal)
58+
if (parent.type !== 'ExportNamedDeclaration' || node.id.name.startsWith('_')) {
59+
return;
60+
}
61+
62+
const previousNodes = [context.sourceCode.getTokenBefore(parent, {includeComments: true})];
63+
64+
// Handle JSDoc blocks for options
65+
if (node.id.name.endsWith('Options') && node.typeAnnotation.type === 'TSTypeLiteral') {
66+
for (const member of node.typeAnnotation.members) {
67+
previousNodes.push(context.sourceCode.getTokenBefore(member, {includeComments: true}));
68+
}
69+
}
70+
71+
for (const previousNode of previousNodes) {
72+
// Skip if previous node is not a JSDoc comment
73+
if (!previousNode || previousNode.type !== 'Block' || !previousNode.value.startsWith('*')) {
74+
continue;
75+
}
76+
77+
const comment = previousNode.value;
78+
79+
for (const match of comment.matchAll(CODEBLOCK_REGEX)) {
80+
const {code, openingFence} = match.groups ?? {};
81+
82+
// Skip empty code blocks
83+
if (!code || !openingFence) {
84+
continue;
85+
}
86+
87+
const matchOffset = match.index + openingFence.length + 2; // Add `2` because `comment` doesn't include the starting `/*`
88+
const codeStartIndex = previousNode.range[0] + matchOffset;
89+
90+
env.updateFile(FILENAME, code);
91+
const syntacticDiagnostics = env.languageService.getSyntacticDiagnostics(FILENAME);
92+
const semanticDiagnostics = env.languageService.getSemanticDiagnostics(FILENAME);
93+
const diagnostics = syntacticDiagnostics.length > 0 ? syntacticDiagnostics : semanticDiagnostics; // Show semantic errors only if there are no syntactic errors
94+
95+
for (const diagnostic of diagnostics) {
96+
// If diagnostic location is not available, report on the entire code block
97+
const diagnosticStart = codeStartIndex + (diagnostic.start ?? 0);
98+
const diagnosticEnd = diagnosticStart + (diagnostic.length ?? code.length);
99+
100+
context.report({
101+
loc: {
102+
start: context.sourceCode.getLocFromIndex(diagnosticStart),
103+
end: context.sourceCode.getLocFromIndex(diagnosticEnd),
104+
},
105+
messageId: 'invalidCodeblock',
106+
data: {
107+
errorMessage: ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'),
108+
},
109+
});
110+
}
111+
}
112+
}
113+
},
114+
};
115+
},
116+
});

0 commit comments

Comments
 (0)