-
Notifications
You must be signed in to change notification settings - Fork 14
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #98 from line/feat/namespace-usage
feat: detect namespace import usage accurately
- Loading branch information
Showing
6 changed files
with
280 additions
and
17 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,119 @@ | ||
import { describe, it } from 'node:test'; | ||
import { namespaceUsage } from './namespaceUsage.js'; | ||
import ts from 'typescript'; | ||
import assert from 'node:assert/strict'; | ||
|
||
describe('namespaceUsage', () => { | ||
it('should return namespace usage for a simple file', () => { | ||
const sourceFile = ts.createSourceFile( | ||
'/app/a.ts', | ||
`import * as b from './b'; | ||
b.x;`, | ||
ts.ScriptTarget.ESNext, | ||
); | ||
|
||
const result = namespaceUsage({ sourceFile }); | ||
|
||
assert.deepEqual(result.get('b'), ['x']); | ||
}); | ||
|
||
it('should return multiple namespace usages', () => { | ||
const sourceFile = ts.createSourceFile( | ||
'/app/a.ts', | ||
`import * as b from './b'; | ||
b.x; | ||
b.y;`, | ||
ts.ScriptTarget.ESNext, | ||
); | ||
|
||
const result = namespaceUsage({ sourceFile }); | ||
|
||
assert.deepEqual(result.get('b'), ['x', 'y']); | ||
}); | ||
|
||
it('should return asterisk if the namespace identifier is used', () => { | ||
const sourceFile = ts.createSourceFile( | ||
'/app/a.ts', | ||
`import * as b from './b'; | ||
b; | ||
b.x;`, | ||
ts.ScriptTarget.ESNext, | ||
); | ||
|
||
const result = namespaceUsage({ sourceFile }); | ||
|
||
assert.deepEqual(result.get('b'), ['*']); | ||
}); | ||
|
||
it('should work with function calls on properties', () => { | ||
const sourceFile = ts.createSourceFile( | ||
'/app/a.ts', | ||
`import * as b from './b'; | ||
b.x(); | ||
b.y.z();`, | ||
ts.ScriptTarget.ESNext, | ||
); | ||
|
||
const result = namespaceUsage({ sourceFile }); | ||
|
||
assert.deepEqual(result.get('b'), ['x', 'y']); | ||
}); | ||
|
||
it('should return an asterisk when the namespace is assigned to a variable', () => { | ||
const sourceFile = ts.createSourceFile( | ||
'/app/a.ts', | ||
`import * as b from './b'; | ||
const c = b; | ||
c.x;`, | ||
ts.ScriptTarget.ESNext, | ||
); | ||
|
||
const result = namespaceUsage({ sourceFile }); | ||
|
||
assert.deepEqual(result.get('b'), ['*']); | ||
}); | ||
|
||
it('should return the correct results when there is a symbol with the same name', () => { | ||
const sourceFile = ts.createSourceFile( | ||
'/app/a.ts', | ||
`import * as b from './b'; | ||
export function f() { | ||
const b = { y: 1 }; | ||
b.y; | ||
} | ||
b.x;`, | ||
ts.ScriptTarget.ESNext, | ||
); | ||
|
||
const result = namespaceUsage({ sourceFile }); | ||
|
||
assert.deepEqual(result.get('b'), ['x']); | ||
}); | ||
|
||
it('should return an empty array when the namespace is not used', () => { | ||
const sourceFile = ts.createSourceFile( | ||
'/app/a.ts', | ||
`import * as b from './b'; | ||
const c = 1;`, | ||
ts.ScriptTarget.ESNext, | ||
); | ||
|
||
const result = namespaceUsage({ sourceFile }); | ||
|
||
assert.deepEqual(result.get('b'), []); | ||
}); | ||
|
||
it('should return asterisk when the namespace is used in a object shorthand', () => { | ||
const sourceFile = ts.createSourceFile( | ||
'/app/a.ts', | ||
`import * as b from './b'; | ||
const c = { b };`, | ||
ts.ScriptTarget.ESNext, | ||
); | ||
|
||
const result = namespaceUsage({ sourceFile }); | ||
|
||
assert.deepEqual(result.get('b'), ['*']); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,90 @@ | ||
import ts from 'typescript'; | ||
import { memoize } from './memoize.js'; | ||
|
||
const fn = ({ sourceFile }: { sourceFile: ts.SourceFile }) => { | ||
const program = createProgram({ sourceFile }); | ||
const checker = program.getTypeChecker(); | ||
|
||
const result = new Map<string, string[]>(); | ||
|
||
const visit = (node: ts.Node) => { | ||
if (ts.isIdentifier(node)) { | ||
const symbol = checker.getSymbolAtLocation(node); | ||
let declaration = symbol?.declarations?.find((d) => d); | ||
|
||
// if it's a shorthand property assignment, we need to find the actual declaration | ||
// ref. https://github.com/microsoft/TypeScript/blob/f69580f82146bebfb2bee8c7b8666af0e04c7e34/src/services/goToDefinition.ts#L253 | ||
while (declaration && ts.isShorthandPropertyAssignment(declaration)) { | ||
const s = checker.getShorthandAssignmentValueSymbol(declaration); | ||
declaration = s?.declarations?.find((d) => d); | ||
} | ||
|
||
if (declaration && ts.isNamespaceImport(declaration)) { | ||
switch (true) { | ||
case ts.isNamespaceImport(node.parent): { | ||
// it's the import statement itself | ||
break; | ||
} | ||
case ts.isPropertyAccessExpression(node.parent): { | ||
const usage = node.parent.name.text; | ||
const importedNamespace = declaration.name.text; | ||
const prev = result.get(importedNamespace) || []; | ||
|
||
if (!prev.includes('*')) { | ||
result.set(importedNamespace, [...prev, usage]); | ||
} | ||
|
||
break; | ||
} | ||
default: { | ||
result.set(declaration.name.text, ['*']); | ||
break; | ||
} | ||
} | ||
} | ||
} | ||
|
||
node.forEachChild(visit); | ||
}; | ||
|
||
sourceFile.forEachChild(visit); | ||
|
||
return { | ||
get(name: string) { | ||
return result.get(name) || []; | ||
}, | ||
}; | ||
}; | ||
|
||
const createProgram = ({ sourceFile }: { sourceFile: ts.SourceFile }) => { | ||
const compilerHost: ts.CompilerHost = { | ||
getSourceFile: (fileName) => { | ||
if (fileName === sourceFile.fileName) { | ||
return sourceFile; | ||
} | ||
|
||
return undefined; | ||
}, | ||
getDefaultLibFileName: (o) => ts.getDefaultLibFilePath(o), | ||
writeFile: () => { | ||
throw new Error('not implemented'); | ||
}, | ||
getCurrentDirectory: () => '/', | ||
fileExists: (fileName) => fileName === sourceFile.fileName, | ||
readFile: (fileName) => | ||
fileName === sourceFile.fileName ? sourceFile.text : undefined, | ||
getCanonicalFileName: (fileName) => | ||
ts.sys.useCaseSensitiveFileNames ? fileName : fileName.toLowerCase(), | ||
useCaseSensitiveFileNames: () => true, | ||
getNewLine: () => '\n', | ||
}; | ||
|
||
// for now, not passing the user's ts.CompilerOptions to ts.createProgram should work | ||
const program = ts.createProgram([sourceFile.fileName], {}, compilerHost); | ||
|
||
return program; | ||
}; | ||
|
||
export const namespaceUsage = memoize(fn, { | ||
key: ({ sourceFile }) => `${sourceFile.fileName}::${sourceFile.text}`, | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.