eslint-plugin-expect-type
Advanced tools
Comparing version 0.1.0 to 0.2.0
@@ -12,4 +12,4 @@ "use strict"; | ||
const snapshot_1 = require("../utils/snapshot"); | ||
const diagnostics_1 = require("../utils/diagnostics"); | ||
const messages = { | ||
TypeScriptCompileError: 'TypeScript compile error: {{ message }}', | ||
FileIsNotIncludedInTsconfig: 'Expected to find a file "{{ fileName }}" present.', | ||
@@ -26,5 +26,2 @@ TypesDoNotMatch: 'Expected type to be: {{ expected }}, got: {{ actual }}', | ||
const defaultOptions = { | ||
// expectError: true, | ||
// expectType: true, | ||
// expectTypeSnapshot: true, | ||
disableExpectTypeSnapshotFix: false, | ||
@@ -37,11 +34,2 @@ }; | ||
properties: { | ||
// expectError: { | ||
// type: 'boolean', | ||
// }, | ||
// expectType: { | ||
// type: 'boolean', | ||
// }, | ||
// expectTypeSnapshot: { | ||
// type: 'boolean', | ||
// }, | ||
disableExpectTypeSnapshotFix: { | ||
@@ -92,13 +80,9 @@ type: 'boolean', | ||
} | ||
if (!/(?:\$Expect(Type|Error|^\?))|\^\?/.test(sourceFile.text)) { | ||
return; | ||
} | ||
const checker = program.getTypeChecker(); | ||
// Don't care about emit errors. | ||
const diagnostics = typescript_1.default.getPreEmitDiagnostics(program, sourceFile); | ||
if (sourceFile.isDeclarationFile || !/\$Expect(Type|Error)/.test(sourceFile.text)) { | ||
// Normal file. | ||
for (const diagnostic of diagnostics) { | ||
addDiagnosticFailure(diagnostic); | ||
} | ||
return; | ||
} | ||
const { errorLines, typeAssertions, duplicates, syntaxErrors } = parseAssertions(sourceFile); | ||
const languageService = typescript_1.default.createLanguageService(getLanguageServiceHost(program)); | ||
const { errorLines, typeAssertions, twoSlashAssertions, duplicates, syntaxErrors } = parseAssertions(sourceFile); | ||
for (const line of duplicates) { | ||
@@ -113,10 +97,3 @@ context.report({ | ||
} | ||
const seenDiagnosticsOnLine = new Set(); | ||
for (const diagnostic of diagnostics) { | ||
const line = lineOfPosition(diagnostic.start, sourceFile); | ||
seenDiagnosticsOnLine.add(line); | ||
if (!errorLines.has(line)) { | ||
addDiagnosticFailure(diagnostic); | ||
} | ||
} | ||
const seenDiagnosticsOnLine = new Set(diagnostics.filter(diagnostics_1.isDiagnosticWithStart).map((diagnostic) => lineOfPosition(diagnostic.start, sourceFile))); | ||
for (const line of errorLines) { | ||
@@ -139,3 +116,5 @@ if (!seenDiagnosticsOnLine.has(line)) { | ||
? '$ExpectType requires type argument (e.g. // $ExpectType "string")' | ||
: '$ExpectTypeSnapshot requires snapshot name argument (e.g. // $ExpectTypeSnapshot MainComponentAPI)', | ||
: type === 'MissingSnapshotName' | ||
? '$ExpectTypeSnapshot requires snapshot name argument (e.g. // $ExpectTypeSnapshot MainComponentAPI)' | ||
: 'Invalid twoslash assertion; make sure there is a space after the "^?".', | ||
}, | ||
@@ -153,3 +132,3 @@ loc: { | ||
} | ||
const { unmetExpectations, unusedAssertions } = getExpectTypeFailures(sourceFile, typeAssertions, checker); | ||
const { unmetExpectations, unusedAssertions } = getExpectTypeFailures(sourceFile, { typeAssertions, twoSlashAssertions }, checker, languageService); | ||
for (const { node, assertion, actual } of unmetExpectations) { | ||
@@ -193,3 +172,17 @@ const templateDescriptor = { | ||
else { | ||
context.report(Object.assign(Object.assign({}, templateDescriptor), { messageId: 'TypesDoNotMatch' })); | ||
context.report(Object.assign(Object.assign(Object.assign({}, templateDescriptor), { messageId: 'TypesDoNotMatch' }), (assertion.assertionType === 'twoslash' | ||
? { | ||
fix: () => { | ||
const { expectedRange, expectedPrefix, insertSpace } = assertion; | ||
return { | ||
range: expectedRange, | ||
text: (insertSpace ? ' ' : '') + | ||
actual | ||
.split('\n') | ||
.map((line, i) => (i > 0 ? expectedPrefix + line : line)) | ||
.join('\n'), | ||
}; | ||
}, | ||
} | ||
: {}))); | ||
} | ||
@@ -206,36 +199,2 @@ } | ||
} | ||
function diagnosticShouldBeIgnored(diagnostic) { | ||
const messageText = typeof diagnostic.messageText === 'string' ? diagnostic.messageText : diagnostic.messageText.messageText; | ||
return /'.+' is declared but (never used|its value is never read)./.test(messageText); | ||
} | ||
function addDiagnosticFailure(diagnostic) { | ||
if (diagnosticShouldBeIgnored(diagnostic)) { | ||
return; | ||
} | ||
if (diagnostic.file === sourceFile) { | ||
const message = `${typescript_1.default.flattenDiagnosticMessageText(diagnostic.messageText, '\n')}`; | ||
context.report({ | ||
messageId: 'TypeScriptCompileError', | ||
data: { | ||
message, | ||
}, | ||
loc: { | ||
line: diagnostic.start + 1, | ||
column: diagnostic.length, | ||
}, | ||
}); | ||
} | ||
else { | ||
context.report({ | ||
messageId: 'TypeScriptCompileError', | ||
data: { | ||
message: `${fileName}${diagnostic.messageText}`, | ||
}, | ||
loc: { | ||
line: 1, | ||
column: 0, | ||
}, | ||
}); | ||
} | ||
} | ||
} | ||
@@ -247,2 +206,3 @@ function parseAssertions(sourceFile) { | ||
const syntaxErrors = []; | ||
const twoSlashAssertions = []; | ||
const { text } = sourceFile; | ||
@@ -259,55 +219,71 @@ const commentRegexp = /\/\/(.*)/g; | ||
// i.e. `// foo; // $ExpectType number` | ||
const match = /^ ?\$Expect(TypeSnapshot|Type|Error)( (.*))?$/.exec(commentMatch[1]); | ||
if (match === null) { | ||
continue; | ||
} | ||
const line = getLine(commentMatch.index); | ||
switch (match[1]) { | ||
case 'TypeSnapshot': | ||
const snapshotName = match[3]; | ||
if (snapshotName) { | ||
if (typeAssertions.delete(line)) { | ||
duplicates.push(line); | ||
const comment = commentMatch[1]; | ||
const matchExpect = /^ ?\$Expect(TypeSnapshot|Type|Error)( (.*))?$/.exec(comment); | ||
const commentIndex = commentMatch.index; | ||
const line = getLine(commentIndex); | ||
if (matchExpect) { | ||
const directive = matchExpect[1]; | ||
const payload = matchExpect[3]; | ||
switch (directive) { | ||
case 'TypeSnapshot': | ||
const snapshotName = payload; | ||
if (snapshotName) { | ||
if (typeAssertions.delete(line)) { | ||
duplicates.push(line); | ||
} | ||
else { | ||
typeAssertions.set(line, { | ||
assertionType: 'snapshot', | ||
snapshotName, | ||
}); | ||
} | ||
} | ||
else { | ||
typeAssertions.set(line, { | ||
assertionType: 'snapshot', | ||
snapshotName, | ||
syntaxErrors.push({ | ||
type: 'MissingSnapshotName', | ||
line, | ||
}); | ||
} | ||
} | ||
else { | ||
syntaxErrors.push({ | ||
type: 'MissingSnapshotName', | ||
line, | ||
}); | ||
} | ||
break; | ||
case 'Error': | ||
if (errorLines.has(line)) { | ||
duplicates.push(line); | ||
} | ||
errorLines.add(line); | ||
break; | ||
case 'Type': | ||
const expected = match[3]; | ||
if (expected) { | ||
// Don't bother with the assertion if there are 2 assertions on 1 line. Just fail for the duplicate. | ||
if (typeAssertions.delete(line)) { | ||
break; | ||
case 'Error': | ||
if (errorLines.has(line)) { | ||
duplicates.push(line); | ||
} | ||
errorLines.add(line); | ||
break; | ||
case 'Type': { | ||
const expected = payload; | ||
if (expected) { | ||
// Don't bother with the assertion if there are 2 assertions on 1 line. Just fail for the duplicate. | ||
if (typeAssertions.delete(line)) { | ||
duplicates.push(line); | ||
} | ||
else { | ||
typeAssertions.set(line, { assertionType: 'manual', expected }); | ||
} | ||
} | ||
else { | ||
typeAssertions.set(line, { assertionType: 'manual', expected }); | ||
syntaxErrors.push({ | ||
type: 'MissingExpectType', | ||
line, | ||
}); | ||
} | ||
break; | ||
} | ||
} | ||
} | ||
else { | ||
// Maybe it's a twoslash assertion | ||
const assertion = parseTwoslashAssertion(comment, commentIndex, line, text, lineStarts); | ||
if (assertion) { | ||
if ('type' in assertion) { | ||
syntaxErrors.push(assertion); | ||
} | ||
else { | ||
syntaxErrors.push({ | ||
type: 'MissingExpectType', | ||
line, | ||
}); | ||
twoSlashAssertions.push(assertion); | ||
} | ||
break; | ||
} | ||
} | ||
} | ||
return { errorLines, typeAssertions, duplicates, syntaxErrors }; | ||
return { errorLines, typeAssertions, duplicates, twoSlashAssertions, syntaxErrors }; | ||
function getLine(pos) { | ||
@@ -323,2 +299,58 @@ // advance curLine to be the line preceding 'pos' | ||
} | ||
function parseTwoslashAssertion(comment, commentIndex, commentLine, sourceText, lineStarts) { | ||
const matchTwoslash = /^( *)\^\?(.*)$/.exec(comment); | ||
if (!matchTwoslash) { | ||
return null; | ||
} | ||
const whitespace = matchTwoslash[1]; | ||
const rawPayload = matchTwoslash[2]; | ||
if (rawPayload.length && rawPayload[0] !== ' ') { | ||
// This is an error: there must be a space after the ^? | ||
return { | ||
type: 'InvalidTwoslash', | ||
line: commentLine - 1, | ||
}; | ||
} | ||
let expected = rawPayload.slice(1); // strip leading space, or leave it as "". | ||
if (commentLine === 1) { | ||
// This will become an attachment error later. | ||
return { | ||
position: -1, | ||
expected, | ||
expectedRange: [-1, -1], | ||
expectedPrefix: '', | ||
insertSpace: false, | ||
}; | ||
} | ||
// The position of interest is wherever the "^" (caret) is, but on the previous line. | ||
const caretIndex = commentIndex + whitespace.length + 2; // 2 = length of "//" | ||
const position = caretIndex - (lineStarts[commentLine - 1] - lineStarts[commentLine - 2]); | ||
const expectedRange = [ | ||
commentIndex + whitespace.length + 5, | ||
commentLine < lineStarts.length ? lineStarts[commentLine] - 1 : sourceText.length, | ||
]; | ||
// Peak ahead to the next lines to see if the expected type continues | ||
const expectedPrefix = sourceText.slice(lineStarts[commentLine - 1], commentIndex + 2 + whitespace.length) + ' '; | ||
for (let nextLine = commentLine; nextLine < lineStarts.length; nextLine++) { | ||
const thisLineEnd = nextLine + 1 < lineStarts.length ? lineStarts[nextLine + 1] - 1 : sourceText.length; | ||
const lineText = sourceText.slice(lineStarts[nextLine], thisLineEnd + 1); | ||
if (lineText.startsWith(expectedPrefix)) { | ||
if (nextLine === commentLine) { | ||
expected += '\n'; | ||
} | ||
expected += lineText.slice(expectedPrefix.length); | ||
expectedRange[1] = thisLineEnd; | ||
} | ||
else { | ||
break; | ||
} | ||
} | ||
let insertSpace = false; | ||
if (expectedRange[0] > expectedRange[1]) { | ||
// this happens if the line ends with "^?" and nothing else | ||
expectedRange[0] = expectedRange[1]; | ||
insertSpace = true; | ||
} | ||
return { position, expected, expectedRange, expectedPrefix, insertSpace }; | ||
} | ||
function isFirstOnLine(text, lineStart, pos) { | ||
@@ -374,3 +406,14 @@ for (let i = lineStart; i < pos; i++) { | ||
} | ||
function getExpectTypeFailures(sourceFile, typeAssertions, checker) { | ||
function getLanguageServiceHost(program) { | ||
return { | ||
getCompilationSettings: () => program.getCompilerOptions(), | ||
getCurrentDirectory: () => program.getCurrentDirectory(), | ||
getDefaultLibFileName: () => 'lib.d.ts', | ||
getScriptFileNames: () => program.getSourceFiles().map((sourceFile) => sourceFile.fileName), | ||
getScriptSnapshot: (name) => { var _a, _b; return typescript_1.default.ScriptSnapshot.fromString((_b = (_a = program.getSourceFile(name)) === null || _a === void 0 ? void 0 : _a.text) !== null && _b !== void 0 ? _b : ''); }, | ||
getScriptVersion: () => '1', | ||
}; | ||
} | ||
function getExpectTypeFailures(sourceFile, assertions, checker, languageService) { | ||
const { typeAssertions, twoSlashAssertions } = assertions; | ||
const unmetExpectations = []; | ||
@@ -384,2 +427,3 @@ // Match assertions to the first node that appears on the line they apply to. | ||
const { expected } = assertion; | ||
let nodeToCheck = node; | ||
// https://github.com/Microsoft/TypeScript/issues/14077 | ||
@@ -389,3 +433,4 @@ if (node.kind === typescript_1.default.SyntaxKind.ExpressionStatement) { | ||
} | ||
const type = checker.getTypeAtLocation(getNodeForExpectType(node)); | ||
nodeToCheck = getNodeForExpectType(node); | ||
const type = checker.getTypeAtLocation(nodeToCheck); | ||
const actual = type | ||
@@ -401,4 +446,48 @@ ? checker.typeToString(type, /*enclosingDeclaration*/ undefined, typescript_1.default.TypeFormatFlags.NoTruncation) | ||
}); | ||
return { unmetExpectations, unusedAssertions: typeAssertions.keys() }; | ||
const twoSlashFailureLines = []; | ||
if (twoSlashAssertions.length) { | ||
for (const assertion of twoSlashAssertions) { | ||
const { position, expected } = assertion; | ||
if (position === -1) { | ||
// special case for a twoslash assertion on line 1. | ||
twoSlashFailureLines.push(0); | ||
continue; | ||
} | ||
const node = getNodeAtPosition(sourceFile, position); | ||
if (!node) { | ||
twoSlashFailureLines.push(sourceFile.getLineAndCharacterOfPosition(position).line); | ||
continue; | ||
} | ||
const qi = languageService.getQuickInfoAtPosition(sourceFile.fileName, node.getStart()); | ||
if (!(qi === null || qi === void 0 ? void 0 : qi.displayParts)) { | ||
twoSlashFailureLines.push(sourceFile.getLineAndCharacterOfPosition(position).line); | ||
continue; | ||
} | ||
const actual = qi.displayParts.map((dp) => dp.text).join(''); | ||
if (!matchModuloWhitespace(actual, expected)) { | ||
unmetExpectations.push({ assertion: Object.assign({ assertionType: 'twoslash' }, assertion), node, actual }); | ||
} | ||
} | ||
} | ||
return { unmetExpectations, unusedAssertions: [...twoSlashFailureLines, ...typeAssertions.keys()] }; | ||
} | ||
function getNodeAtPosition(sourceFile, position) { | ||
let candidate = undefined; | ||
typescript_1.default.forEachChild(sourceFile, function iterate(node) { | ||
const start = node.getStart(); | ||
const end = node.getEnd(); | ||
if (position >= start && position <= end) { | ||
candidate = node; | ||
typescript_1.default.forEachChild(node, iterate); | ||
} | ||
}); | ||
return candidate; | ||
} | ||
function matchModuloWhitespace(actual, expected) { | ||
// TODO: it's much easier to normalize actual based on the displayParts | ||
// This isn't 100% correct if a type has a space in it, e.g. type T = "string literal" | ||
const normActual = actual.replace(/[\n ]+/g, ' ').trim(); | ||
const normExpected = expected.replace(/[\n ]+/g, ' ').trim(); | ||
return normActual === normExpected; | ||
} | ||
function getNodeForExpectType(node) { | ||
@@ -405,0 +494,0 @@ if (node.kind === typescript_1.default.SyntaxKind.VariableStatement) { |
{ | ||
"name": "eslint-plugin-expect-type", | ||
"version": "0.1.0", | ||
"version": "0.2.0", | ||
"description": "ESLint plugin with $ExpectType, $ExpectError and $ExpectTypeSnapshot type assertions", | ||
@@ -5,0 +5,0 @@ "author": { |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
New author
Supply chain riskA new npm collaborator published a version of the package for the first time. New collaborators are usually benign additions to a project, but do indicate a change to the security surface area of a package.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
39818
12
585
1