Skip to content

Commit

Permalink
feat: static import analyzes tools (#3)
Browse files Browse the repository at this point in the history
Co-authored-by: Daniel Roe <daniel@roe.dev>
  • Loading branch information
pi0 and danielroe committed Sep 30, 2021
1 parent ceb6e45 commit 8193226
Show file tree
Hide file tree
Showing 15 changed files with 2,431 additions and 52 deletions.
1 change: 1 addition & 0 deletions .eslintignore
@@ -1,3 +1,4 @@
node_modules
coverage
dist
test/fixture/imports.mjs
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Expand Up @@ -20,3 +20,4 @@ jobs:
cache: yarn
- run: yarn install
- run: yarn lint
- run: yarn test
98 changes: 98 additions & 0 deletions README.md
Expand Up @@ -40,6 +40,13 @@ While ESM Modules are evolving in Node.js ecosystem, there are still many requir
- Stack-trace support
- `.json` loader
- Multiple composable module utils exposed
- Static import analyzes
- Super fast Regex based implementation
- Handle most of edge cases
- Find all static ESM imports
- Find all dynamic ESM imports
- Parse static import statement


## CommonJS Context

Expand Down Expand Up @@ -166,6 +173,97 @@ console.log(transformModule(`console.log(import.meta.url)`), { url: 'test.mjs' }
Options are same as `evalModule`.
## Import analyzes
### `findStaticImports`
Find all static ESM imports.
Example:
```js
import { findStaticImports } from 'mlly'

console.log(findStaticImports(`
// Empty line
import foo, { bar /* foo */ } from 'baz'
`))
```
Outputs:
```js
[
{
type: 'static',
imports: 'foo, { bar /* foo */ } ',
specifier: 'baz',
code: "import foo, { bar /* foo */ } from 'baz'",
start: 15,
end: 55
}
]
```
### `parseStaticImport`
Parse a dynamic ESM import statement previusly matched by `findStaticImports`.
Example:
```js
import { findStaticImports, parseStaticImport } from 'mlly'

const [match0] = findStaticImports(`import baz, { x, y as z } from 'baz'`)
console.log(parseStaticImport(match0))
```
Outputs:
```js
{
type: 'static',
imports: 'baz, { x, y as z } ',
specifier: 'baz',
code: "import baz, { x, y as z } from 'baz'",
start: 0,
end: 36,
defaultImport: 'baz',
namespacedImport: undefined,
namedImports: { x: 'x', y: 'z' }
}
```
### `findDynamicImports`
Find all dynamic ESM imports.
Example:
```js
import { findStaticImports } from 'mlly'

console.log(findDynamicImports(`
const foo = await import('bar')
`))
```
Outputs:
```js
[
{
type: 'dynamic',
expression: "'bar'",
code: "import('bar')",
start: 19,
end: 32
}
]
```
## Other Utils
Expand Down
56 changes: 54 additions & 2 deletions lib/index.mjs
Expand Up @@ -109,7 +109,8 @@ export function createResolve (defaults) {

// Evaluate

const ESM_IMPORT_RE = /(?<=import .* from ['"])([^'"]+)(?=['"])|(?<=export .* from ['"])([^'"]+)(?=['"])|(?<=import\s*['"])([^'"]+)(?=['"])|(?<=import\s*\(['"])([^'"]+)(?=['"]\))/g
// TODO: Migrate to new Regexes
const EVAL_ESM_IMPORT_RE = /(?<=import .* from ['"])([^'"]+)(?=['"])|(?<=export .* from ['"])([^'"]+)(?=['"])|(?<=import\s*['"])([^'"]+)(?=['"])|(?<=import\s*\(['"])([^'"]+)(?=['"]\))/g

export async function loadModule (id, opts = {}) {
const url = await resolve(id, opts)
Expand Down Expand Up @@ -144,7 +145,7 @@ export async function transformModule (code, opts) {
}

export async function resolveImports (code, opts) {
const imports = Array.from(code.matchAll(ESM_IMPORT_RE)).map(m => m[0])
const imports = Array.from(code.matchAll(EVAL_ESM_IMPORT_RE)).map(m => m[0])
if (!imports.length) {
return code
}
Expand All @@ -164,6 +165,57 @@ export async function resolveImports (code, opts) {
return code.replace(re, id => resolved.get(id))
}

// Import analyzes

export const ESM_STATIC_IMPORT_RE = /^(?<=\s*)import\s*(["'\s]*(?<imports>[\w*${}\n\r\t, /]+)from\s*)?["']\s*(?<specifier>.*[@\w_-]+)\s*["'][^\n]*$/gm
export const DYNAMIC_IMPORT_RE = /import\s*\((?<expression>(?:[^)(]+|\((?:[^)(]+|\([^)(]*\))*\))*)\)/gm

function _matchAll (regex, string, addition) {
const matches = []
for (const match of string.matchAll(regex)) {
matches.push({
...addition,
...match.groups,
code: match[0],
start: match.index,
end: match.index + match[0].length
})
}
return matches
}

export function findStaticImports (code) {
return _matchAll(ESM_STATIC_IMPORT_RE, code, { type: 'static' })
}

export function findDynamicImports (code) {
return _matchAll(DYNAMIC_IMPORT_RE, code, { type: 'dynamic' })
}

export function parseStaticImport (matched) {
const cleanedImports = (matched.imports || '')
.replace(/(\/\/[^\n]*\n|\/\*.*\*\/)/g, '')
.replace(/\s+/g, ' ')

const namedImports = {}
for (const namedImport of cleanedImports.match(/\{([^}]*)\}/)?.[1]?.split(',') || []) {
const [, source = namedImport.trim(), importName = source] = namedImport.match(/^\s*([^\s]*) as ([^\s]*)\s*$/) || []
if (source) {
namedImports[source] = importName
}
}
const topLevelImports = cleanedImports.replace(/\{([^}]*)\}/, '')
const namespacedImport = topLevelImports.match(/\* as \s*([^\s]*)/)?.[1]
const defaultImport = topLevelImports.split(',').find(i => !i.match(/[*{}]/))?.trim() || undefined

return {
...matched,
defaultImport,
namespacedImport,
namedImports
}
}

// Utils

export function fileURLToPath (id) {
Expand Down
5 changes: 4 additions & 1 deletion package.json
Expand Up @@ -15,15 +15,18 @@
"scripts": {
"lint": "eslint --ext .mjs lib",
"release": "yarn test && standard-version && npm publish && git push --follow-tags",
"test": "yarn lint"
"test": "mocha ./test/**/*.test.mjs"
},
"dependencies": {
"import-meta-resolve": "^1.1.1"
},
"devDependencies": {
"@nuxtjs/eslint-config": "latest",
"@types/node": "latest",
"chai": "^4.3.4",
"eslint": "latest",
"jiti": "^1.12.5",
"mocha": "^9.1.2",
"standard-version": "latest"
}
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
131 changes: 131 additions & 0 deletions test/imports.test.mjs
@@ -0,0 +1,131 @@
import { expect } from 'chai'
import { findDynamicImports, findStaticImports, parseStaticImport } from '../lib/index.mjs'

// -- Static import --

const staticTests = {

'import defaultMember from "module-name";': {
specifier: 'module-name',
defaultImport: 'defaultMember'
},
'import * as name from "module-name ";': {
specifier: 'module-name',
namespacedImport: 'name'
},
'import * as name from "module-name "; //test': {
specifier: 'module-name',
namespacedImport: 'name'
},
'import { member } from " module-name";': {
specifier: 'module-name',
namedImports: { member: 'member' }
},
'import { member as alias } from "module-name";': {
specifier: 'module-name',
namedImports: { member: 'alias' }
},
'import { member1, member2 as alias2, member3 as alias3 } from "module-name";': {
specifier: 'module-name',
namedImports: { member1: 'member1', member2: 'alias2', member3: 'alias3' }
},
'import { member1, /* member0point5, */ member2 as alias2, member3 as alias3 } from "module-name";': {
specifier: 'module-name',
namedImports: { member1: 'member1', member2: 'alias2', member3: 'alias3' }
},
'import defaultMember, { member, /* test */ member } from "module-name";': {
specifier: 'module-name',
defaultImport: 'defaultMember',
namedImports: { member: 'member' }
},
'import defaultMember, * as name from "module-name";': {
specifier: 'module-name',
defaultImport: 'defaultMember',
namespacedImport: 'name'
},
'import "module-name";': {
specifier: 'module-name'
}
}

staticTests[`import {
member1,
// test
member2
} from "module-name";`] = {
specifier: 'module-name',
namedImports: { member1: 'member1', member2: 'member2' }
}

staticTests[`import {
member1,
member2
} from "module-name";`] = {
specifier: 'module-name',
namedImports: { member1: 'member1', member2: 'member2' }
}

staticTests[`import {
Component
} from '@angular2/core';`] = {
specifier: '@angular2/core',
type: 'static',
namedImports: { Component: 'Component' }
}

// -- Dynamic import --
const dynamicTests = {
'const { test, /* here */, another, } = await import ( "module-name" );': {
expression: '"module-name"'
},
'var promise = import ( "module-name" );': {
expression: '"module-name"'
},
'import ( "module-name" );': {
expression: '"module-name"'
},
'import(foo("123"))': {
expression: 'foo("123")'
},
'import("abc").then(r => r.default)': {
expression: '"abc"'
}
}

describe('findStaticImports', () => {
for (const [input, test] of Object.entries(staticTests)) {
it(input.replace(/\n/g, '\\n'), () => {
const matches = findStaticImports(input)
expect(matches.length).to.equal(1)

const match = matches[0]
expect(match.type).to.equal('static')

expect(match.specifier).to.equal(test.specifier)

const parsed = parseStaticImport(match)
if (test.defaultImport) {
expect(parsed.defaultImport).to.equals(test.defaultImport)
}
if (test.namedImports) {
expect(parsed.namedImports).to.eql(test.namedImports)
}
if (test.namespacedImport) {
expect(parsed.namespacedImport).to.eql(test.namespacedImport)
}
})
}
})

describe('findDynamicImports', () => {
for (const [input, test] of Object.entries(dynamicTests)) {
it(input.replace(/\n/g, '\\n'), () => {
const matches = findDynamicImports(input)
expect(matches.length).to.equal(1)
const match = matches[0]
expect(match.type).to.equal('dynamic')
expect(match.expression.trim()).to.equal(test.expression)
})
}
})

0 comments on commit 8193226

Please sign in to comment.