Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(findExports): use acorn tokenizer to filter false positive exports #56

Merged
merged 14 commits into from Aug 3, 2022
Merged
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -24,6 +24,7 @@
"test": "pnpm lint && vitest run"
},
"dependencies": {
"acorn": "^8.7.1",
"pathe": "^0.3.1",
"pkg-types": "^0.3.3"
},
Expand Down
65 changes: 11 additions & 54 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 35 additions & 3 deletions src/analyze.ts
@@ -1,3 +1,4 @@
import { tokenizer } from 'acorn'
import { matchAll } from './_utils'

export interface ESMImport {
Expand Down Expand Up @@ -110,7 +111,6 @@ export function findExports (code: string): ESMExport[] {

// Merge and normalize exports
const exports = [].concat(declaredExports, namedExports, defaultExport, starExports)

for (const exp of exports) {
if (!exp.name && exp.names && exp.names.length === 1) {
exp.name = exp.names[0]
Expand All @@ -123,10 +123,42 @@ export function findExports (code: string): ESMExport[] {
exp.names = [exp.name]
}
}

const exportsLocation = getExportTokenLocation(code)
pi0 marked this conversation as resolved.
Show resolved Hide resolved
return exports.filter((exp, index, exports) => {
// Filter out noise that does not match the semantics of export
if (!isExportStatement(exportsLocation, exp)) {
return false
}
// Prevent multiple exports of same function, only keep latest iteration of signatures
const nextExport = exports[index + 1]
return !nextExport || exp.type !== nextExport.type || !exp.name || exp.name !== nextExport.name
return isExportStatement(exportsLocation, exp) || !nextExport || exp.type !== nextExport.type || !exp.name || exp.name !== nextExport.name
pi0 marked this conversation as resolved.
Show resolved Hide resolved
})
}

interface TokenLocation {
start: number
end: number
}
function isExportStatement (exportsLcation: TokenLocation[], exp) {
hubvue marked this conversation as resolved.
Show resolved Hide resolved
pi0 marked this conversation as resolved.
Show resolved Hide resolved
return exportsLcation.some(location => exp.start <= location.start && exp.end >= location.end)
}

function getExportTokenLocation (code: string) {
const tokens = tokenizer(code, {
ecmaVersion: 'latest',
sourceType: 'module',
allowHashBang: true,
allowAwaitOutsideFunction: true,
allowImportExportEverywhere: true
})
const locations: TokenLocation[] = []
for (const token of tokens) {
if (token.type.label === 'export') {
locations.push({
start: token.start,
end: token.end
})
}
}
return locations
}
75 changes: 67 additions & 8 deletions test/exports.test.ts
Expand Up @@ -36,20 +36,79 @@ describe('findExports', () => {
}
it('handles multiple exports', () => {
const matches = findExports(`
export { useTestMe1 } from "@/test/foo1";
export { useTestMe2 } from "@/test/foo2";
export { useTestMe3 } from "@/test/foo3";
`)
export { useTestMe1 } from "@/test/foo1";
export { useTestMe2 } from "@/test/foo2";
export { useTestMe3 } from "@/test/foo3";
`)
expect(matches.length).to.eql(3)
})

it('works with multiple named exports', () => {
const code = `
export { foo } from 'foo1';
export { bar } from 'foo2';
export { foobar } from 'foo2';
`
export { foo } from 'foo1';
export { bar } from 'foo2';
export { foobar } from 'foo2';
`
const matches = findExports(code)
expect(matches).to.have.lengthOf(3)
})

it('the commented out export should be filtered out', () => {
const code = `
// export { foo } from 'foo1';
// exports default 'foo';
// export { useB, _useC as useC };
// export function useA () { return 'a' }
// export { default } from "./other"
// export async function foo () {}
// export { foo as default }
//export * from "./other"
//export * as foo from "./other"

/**
* export const a = 123
* export { foo } from 'foo1';
* exports default 'foo'
* export function useA () { return 'a' }
* export { useB, _useC as useC };
*export { default } from "./other"
*export async function foo () {}
* export { foo as default }
* export * from "./other"
export * as foo from "./other"
*/
export { bar } from 'foo2';
export { foobar } from 'foo2';
`
const matches = findExports(code)
expect(matches).to.have.lengthOf(2)
})
it('export in string', () => {
const tests: string[] = [
'export function useA () { return \'a\' }',
'export const useD = () => { return \'d\' }',
'export { useB, _useC as useC }',
'export default foo',
'export { default } from "./other"',
'export async function foo ()',
'export const $foo = () => {}',
'export { foo as default }',
'export * from "./other"',
'export * as foo from "./other"'
]
const code = tests.reduce((codeStr, statement, idx) => {
codeStr = `
${codeStr}
const test${idx}0 = "${statement}"
const test${idx}1 = \`
test1
${statement}
test2
\`
`
return codeStr
}, 'export { bar } from \'foo2\'; \n export { foobar } from \'foo2\';')
const matches = findExports(code)
expect(matches).to.have.lengthOf(2)
})
})