Skip to content
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
9847e5a
feat: validate twoslash type hints
som-sm Dec 3, 2025
7193a83
refactor: manipulate `displayParts` to use tabs and single quotes
som-sm Dec 3, 2025
5d89b9f
fix: skip type mismatch checks if there are diagnostic errors
som-sm Dec 4, 2025
0257077
refactor: improve type extraction logic
som-sm Dec 4, 2025
235cdc5
chore: add comment to disable lint warning
som-sm Dec 4, 2025
03d9daa
chore: update error message formatting
som-sm Dec 4, 2025
7fd43de
test: add tests
som-sm Dec 4, 2025
d74faf2
feat: add support for tweaking compiler options from codeblocks
som-sm Dec 4, 2025
958c99f
feat: collapse short types into single line
som-sm Dec 5, 2025
b17cbea
fix: type extraction with mismatched delimiter
som-sm Dec 5, 2025
db0d0ef
refactor: compare the entire comment instead of just the type
som-sm Dec 5, 2025
4d278a1
chore: update variable name
som-sm Dec 5, 2025
2cf4acc
style: update formatting
som-sm Dec 5, 2025
8110598
refactor: improve logic used for converting type into single line
som-sm Dec 5, 2025
00b114e
refactor: increase single line conversion threshold
som-sm Dec 5, 2025
3c6ce93
fix: twoslash errors in codeblocks
som-sm Dec 5, 2025
2e84c26
fix: replace `// ^?` with `// =>`
som-sm Dec 5, 2025
d105eed
chore: fix typo in comment
som-sm Dec 5, 2025
8c6ad5f
test: add more cases
som-sm Dec 7, 2025
25c03e0
refactor: fix `max-depth` and `complexity` lint issue
som-sm Dec 8, 2025
3399ebd
refactor: improve check
som-sm Dec 20, 2025
9b2b016
fix: handle cases where JSDoc comment is not immediately before the type
som-sm Dec 20, 2025
cbae4e2
chore: remove unnecessary comment within codeblock
som-sm Dec 20, 2025
c486c5b
fix: update stale variable name
som-sm Dec 20, 2025
480f7f9
fix: incorrectly written twoslash comments
som-sm Dec 20, 2025
9db55aa
refactor: extract logic into function
som-sm Dec 22, 2025
45f7fb4
chore: add comment regarding `Simplify` usage
som-sm Dec 23, 2025
6f5598c
Sort numbers in unions while validating twoslash (`//=>`) types in co…
som-sm Dec 25, 2025
650a3b7
Merge branch 'main' into feat/validate-twoslash-type-hints-in-codeblocks
som-sm Dec 25, 2025
5265b61
fix: twoslash types in `ArrayReverse`
som-sm Dec 25, 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
203 changes: 194 additions & 9 deletions lint-rules/validate-jsdoc-codeblocks.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {createFSBackedSystem, createVirtualTypeScriptEnvironment} from '@typescr

const CODEBLOCK_REGEX = /(?<openingFence>```(?:ts|typescript)?\n)(?<code>[\s\S]*?)```/g;
const FILENAME = 'example-codeblock.ts';
const TWOSLASH_COMMENT = '//=>';

const compilerOptions = {
lib: ['lib.es2023.d.ts', 'lib.dom.d.ts', 'lib.dom.iterable.d.ts'],
Expand All @@ -28,16 +29,62 @@ virtualFsMap.set(FILENAME, '// Can\'t be empty');

const rootDir = path.join(import.meta.dirname, '..');
const system = createFSBackedSystem(virtualFsMap, rootDir, ts);
const env = createVirtualTypeScriptEnvironment(system, [FILENAME], ts, compilerOptions);
const defaultEnv = createVirtualTypeScriptEnvironment(system, [FILENAME], ts, compilerOptions);

function parseCompilerOptions(code) {
const options = {};
const lines = code.split('\n');

for (const line of lines) {
if (!line.trim()) {
// Skip empty lines
continue;
}

const match = line.match(/^\s*\/\/ @(\w+): (.*)$/);
if (!match) {
// Stop parsing at the first non-matching line
return options;
}

const [, key, value] = match;
const trimmedValue = value.trim();

try {
options[key] = JSON.parse(trimmedValue);
} catch {
options[key] = trimmedValue;
}
}

return options;
}

function getJSDocNode(sourceCode, node) {
let previousToken = sourceCode.getTokenBefore(node, {includeComments: true});

// Skip over any line comments immediately before the node
while (previousToken && previousToken.type === 'Line') {
previousToken = sourceCode.getTokenBefore(previousToken, {includeComments: true});
}

if (previousToken && previousToken.type === 'Block' && previousToken.value.startsWith('*')) {
return previousToken;
}

return undefined;
}

export const validateJSDocCodeblocksRule = /** @type {const} */ ({
meta: {
type: 'suggestion',
docs: {
description: 'Ensures JSDoc example codeblocks don\'t have errors',
},
fixable: 'code',
messages: {
invalidCodeblock: '{{errorMessage}}',
typeMismatch: 'Expected twoslash comment to be: {{expectedComment}}, but found: {{actualComment}}',
},
schema: [],
},
Expand All @@ -51,7 +98,7 @@ export const validateJSDocCodeblocksRule = /** @type {const} */ ({
}

try {
env.updateFile(context.filename, context.sourceCode.getText());
defaultEnv.updateFile(context.filename, context.sourceCode.getText());
} catch {
// Ignore
}
Expand All @@ -65,21 +112,23 @@ export const validateJSDocCodeblocksRule = /** @type {const} */ ({
return;
}

const previousNodes = [context.sourceCode.getTokenBefore(parent, {includeComments: true})];
const previousNodes = [];
const jsdocForExport = getJSDocNode(context.sourceCode, parent);
if (jsdocForExport) {
previousNodes.push(jsdocForExport);
}

// Handle JSDoc blocks for options
if (node.id.name.endsWith('Options') && node.typeAnnotation.type === 'TSTypeLiteral') {
for (const member of node.typeAnnotation.members) {
previousNodes.push(context.sourceCode.getTokenBefore(member, {includeComments: true}));
const jsdocForMember = getJSDocNode(context.sourceCode, member);
if (jsdocForMember) {
previousNodes.push(jsdocForMember);
}
}
}

for (const previousNode of previousNodes) {
// Skip if previous node is not a JSDoc comment
if (!previousNode || previousNode.type !== 'Block' || !previousNode.value.startsWith('*')) {
continue;
}

const comment = previousNode.value;

for (const match of comment.matchAll(CODEBLOCK_REGEX)) {
Expand All @@ -93,6 +142,18 @@ export const validateJSDocCodeblocksRule = /** @type {const} */ ({
const matchOffset = match.index + openingFence.length + 2; // Add `2` because `comment` doesn't include the starting `/*`
const codeStartIndex = previousNode.range[0] + matchOffset;

const overrides = parseCompilerOptions(code);
let env = defaultEnv;

if (Object.keys(overrides).length > 0) {
const {options, errors} = ts.convertCompilerOptionsFromJson(overrides, rootDir);

if (errors.length === 0) {
// Create a new environment with overridden options
env = createVirtualTypeScriptEnvironment(system, [FILENAME], ts, {...compilerOptions, ...options});
}
}

env.updateFile(FILENAME, code);
const syntacticDiagnostics = env.languageService.getSyntacticDiagnostics(FILENAME);
const semanticDiagnostics = env.languageService.getSemanticDiagnostics(FILENAME);
Expand All @@ -114,9 +175,133 @@ export const validateJSDocCodeblocksRule = /** @type {const} */ ({
},
});
}

if (diagnostics.length === 0) {
validateTwoslashTypes(context, env, code, codeStartIndex);
}
}
}
},
};
},
});

function validateTwoslashTypes(context, env, code, codeStartIndex) {
const sourceFile = env.languageService.getProgram().getSourceFile(FILENAME);
const lines = code.split('\n');

for (const [index, line] of lines.entries()) {
const dedentedLine = line.trimStart();
if (!dedentedLine.startsWith(TWOSLASH_COMMENT)) {
continue;
}

const previousLineIndex = index - 1;
if (previousLineIndex < 0) {
continue;
}

let actualComment = dedentedLine;
let actualCommentEndLine = index;

for (let i = index + 1; i < lines.length; i++) {
const dedentedNextLine = lines[i].trimStart();
if (!dedentedNextLine.startsWith('//') || dedentedNextLine.startsWith(TWOSLASH_COMMENT)) {
break;
}

actualComment += '\n' + dedentedNextLine;
actualCommentEndLine = i;
}

const previousLine = lines[previousLineIndex];
const previousLineOffset = sourceFile.getPositionOfLineAndCharacter(previousLineIndex, 0);

for (let i = 0; i < previousLine.length; i++) {
const quickInfo = env.languageService.getQuickInfoAtPosition(FILENAME, previousLineOffset + i);

if (quickInfo?.displayParts) {
let depth = 0;
const separatorIndex = quickInfo.displayParts.findIndex(part => {
if (part.kind === 'punctuation') {
if (['(', '{', '<'].includes(part.text)) {
depth++;
} else if ([')', '}', '>'].includes(part.text)) {
depth--;
} else if (part.text === ':' && depth === 0) {
return true;
}
} else if (part.kind === 'operator' && part.text === '=' && depth === 0) {
return true;
}

return false;
});

let partsToUse = quickInfo.displayParts;
if (separatorIndex !== -1) {
partsToUse = quickInfo.displayParts.slice(separatorIndex + 1);
}

let expectedType = partsToUse.map((part, index) => {
const {kind, text} = part;

// Replace spaces used for indentation with tabs
const previousPart = partsToUse[index - 1];
if (kind === 'space' && (index === 0 || previousPart?.kind === 'lineBreak')) {
return text.replaceAll(' ', '\t');
}

// Replace double-quoted string literals with single-quoted ones
if (kind === 'stringLiteral' && text.startsWith('"') && text.endsWith('"')) {
return `'${text.slice(1, -1).replaceAll(String.raw`\"`, '"').replaceAll('\'', String.raw`\'`)}'`;
}

return text;
}).join('').trim();

if (expectedType.length < 80) {
expectedType = expectedType
.replaceAll(/\r?\n\s*/g, ' ') // Collapse into single line
.replaceAll(/{\s+/g, '{') // Remove spaces after `{`
.replaceAll(/\s+}/g, '}') // Remove spaces before `}`
.replaceAll(/;(?=})/g, ''); // Remove semicolons before `}`
}

const expectedComment = TWOSLASH_COMMENT + ' ' + expectedType.replaceAll('\n', '\n// ');

if (actualComment !== expectedComment) {
const actualCommentIndex = line.indexOf(TWOSLASH_COMMENT);

const actualCommentStartOffset = sourceFile.getPositionOfLineAndCharacter(index, actualCommentIndex);
const actualCommentEndOffset = sourceFile.getPositionOfLineAndCharacter(actualCommentEndLine, lines[actualCommentEndLine].length);

const start = codeStartIndex + actualCommentStartOffset;
const end = codeStartIndex + actualCommentEndOffset;

context.report({
loc: {
start: context.sourceCode.getLocFromIndex(start),
end: context.sourceCode.getLocFromIndex(end),
},
messageId: 'typeMismatch',
data: {
expectedComment,
actualComment,
},
fix(fixer) {
const indent = line.slice(0, actualCommentIndex);

return fixer.replaceTextRange(
[start, end],
expectedComment.replaceAll('\n', `\n${indent}`),
);
},
});
}

break;
}
}
}
}
Loading