Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
138 changes: 89 additions & 49 deletions lint-rules/validate-jsdoc-codeblocks.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ export const validateJSDocCodeblocksRule = /** @type {const} */ ({
fixable: 'code',
messages: {
invalidCodeblock: '{{errorMessage}}',
typeMismatch: 'Expected twoslash comment to be: {{expectedComment}}, but found: {{actualComment}}',
incorrectTwoslashType: 'Expected twoslash comment to be: {{expectedComment}}, but found: {{actualComment}}',
incorrectTwoslashFormat: 'Expected twoslash comment to be: {{expectedComment}}, but found: {{actualComment}}',
Comment on lines +87 to +88
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

While the error message is the same for both cases, having two different error types helps in writing tests in a more controlled fashion.

},
schema: [],
},
Expand Down Expand Up @@ -186,6 +187,15 @@ export const validateJSDocCodeblocksRule = /** @type {const} */ ({
},
});

function getLeftmostQuickInfo(env, line, lineOffset) {
for (let i = 0; i < line.length; i++) {
const quickInfo = env.languageService.getQuickInfoAtPosition(FILENAME, lineOffset + i);
if (quickInfo?.displayParts) {
return quickInfo;
}
}
}

function extractTypeFromQuickInfo(quickInfo) {
const {displayParts} = quickInfo;

Expand Down Expand Up @@ -219,7 +229,7 @@ function extractTypeFromQuickInfo(quickInfo) {
return displayParts.slice(separatorIndex + 1).map(part => part.text).join('').trim();
}

function normalizeUnions(type) {
function normalizeType(type, onlySortNumbers = false) {
const sourceFile = ts.createSourceFile(
'twoslash-type.ts',
`declare const test: ${type};`,
Expand All @@ -229,19 +239,25 @@ function normalizeUnions(type) {
const typeNode = sourceFile.statements[0].declarationList.declarations[0].type;

const print = node => ts.createPrinter().printNode(ts.EmitHint.Unspecified, node, sourceFile);

const isNumeric = v => v.trim() !== '' && Number.isFinite(Number(v));

const visit = node => {
node = ts.visitEachChild(node, visit, undefined);

if (ts.isUnionTypeNode(node)) {
const types = node.types
.map(t => [print(t), t])
.sort(([a], [b]) =>
// Numbers are sorted only wrt other numbers
isNumeric(a) && isNumeric(b) ? Number(a) - Number(b) : 0,
)
.map(t => t[1]);
let types = node.types.map(t => [print(t), t]);

if (onlySortNumbers) {
// Sort only numeric members while keeping non-numeric members at their original positions
const sortedNumericTypes = types.filter(([a]) => isNumeric(a)).sort(([a], [b]) => Number(a) - Number(b));
let numericIndex = 0;
types = types.map(t => isNumeric(t[0]) ? sortedNumericTypes[numericIndex++][1] : t[1]);
} else {
types = types
.sort(([a], [b]) => a < b ? -1 : (a > b ? 1 : 0))
.map(t => t[1]);
}

return ts.factory.updateUnionTypeNode(
node,
Expand Down Expand Up @@ -276,6 +292,34 @@ function normalizeUnions(type) {
});
}

function getCommentForType(type) {
let comment = type;

if (type.length < 80) {
comment = type
.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 `}`
}

return `${TWOSLASH_COMMENT} ${comment.replaceAll('\n', '\n// ')}`;
}

function reportTypeMismatch({context, messageId, start, end, data, fix}) {
context.report({
loc: {
start: context.sourceCode.getLocFromIndex(start),
end: context.sourceCode.getLocFromIndex(end),
},
messageId,
data,
fix(fixer) {
return fixer.replaceTextRange([start, end], fix);
},
});
}

function validateTwoslashTypes(context, env, code, codeStartIndex) {
const sourceFile = env.languageService.getProgram().getSourceFile(FILENAME);
const lines = code.split('\n');
Expand All @@ -291,6 +335,7 @@ function validateTwoslashTypes(context, env, code, codeStartIndex) {
continue;
}

let rawActualType = dedentedLine.slice(TWOSLASH_COMMENT.length);
let actualComment = dedentedLine;
let actualCommentEndLine = index;

Expand All @@ -301,59 +346,54 @@ function validateTwoslashTypes(context, env, code, codeStartIndex) {
}

actualComment += '\n' + dedentedNextLine;
rawActualType += '\n' + dedentedNextLine.slice(2); // Remove the `//` from start
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);
const actualCommentIndex = line.indexOf(TWOSLASH_COMMENT);

if (quickInfo?.displayParts) {
let expectedType = normalizeUnions(extractTypeFromQuickInfo(quickInfo));
const actualCommentStartOffset = sourceFile.getPositionOfLineAndCharacter(index, actualCommentIndex);
const actualCommentEndOffset = sourceFile.getPositionOfLineAndCharacter(actualCommentEndLine, lines[actualCommentEndLine].length);

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 start = codeStartIndex + actualCommentStartOffset;
const end = codeStartIndex + actualCommentEndOffset;

const expectedComment = TWOSLASH_COMMENT + ' ' + expectedType.replaceAll('\n', '\n// ');
const indent = line.slice(0, actualCommentIndex);

const quickInfo = getLeftmostQuickInfo(env, previousLine, previousLineOffset);

if (quickInfo?.displayParts) {
const expectedType = normalizeType(extractTypeFromQuickInfo(quickInfo));
const actualType = normalizeType(rawActualType);

if (actualType === expectedType) {
// If the types are equal, check for formatting errors and unordered numbers in unions
const expectedComment = getCommentForType(normalizeType(rawActualType, true));

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}`),
);
},
reportTypeMismatch({
messageId: 'incorrectTwoslashFormat',
context,
start,
end,
data: {expectedComment, actualComment},
fix: expectedComment.replaceAll('\n', `\n${indent}`),
});
}

break;
} else {
const expectedComment = getCommentForType(expectedType);

reportTypeMismatch({
messageId: 'incorrectTwoslashType',
context,
start,
end,
data: {expectedComment, actualComment},
fix: expectedComment.replaceAll('\n', `\n${indent}`),
});
}
}
}
Expand Down
Loading