export type SourceToken = { kind: 'identifier' | 'string' | 'punctuation' value: string offset: number length: number line: number column: number } export type SourceImport = { specifier: string token: SourceToken bindings: ReadonlyMap namespace?: string } export type SourceFile = { path: string relativePath: string text: string tokens: readonly SourceToken[] imports: readonly SourceImport[] } const identifierStart = /[A-Za-z_$]/ const identifierPart = /[A-Za-z0-9_$]/ export function scanSource(path: string, relativePath: string, text: string): SourceFile { const tokens = tokenize(text) return { path, relativePath, text, tokens, imports: parseImports(tokens) } } function tokenize(text: string): SourceToken[] { const tokens: SourceToken[] = [] let offset = 0 let line = 1 let column = 1 const advance = (): string => { const char = text[offset++]! if (char === '\n') { line++ column = 1 } else { column++ } return char } while (offset < text.length) { const char = text[offset]! if (/\s/.test(char)) { advance() continue } if (char === '/' && text[offset + 1] === '/') { advance() advance() while (offset < text.length && text[offset] !== '\n') advance() continue } if (char === '/' && text[offset + 1] === '*') { advance() advance() while (offset < text.length) { if (text[offset] === '*' && text[offset + 1] === '/') { advance() advance() break } advance() } continue } if (char === "'" || char === '"') { const quote = char const start = offset const startLine = line const startColumn = column advance() let value = '' while (offset < text.length) { const current = advance() if (current === '\\') { if (offset < text.length) value += decodeEscape(advance()) continue } if (current === quote) break value += current } tokens.push({ kind: 'string', value, offset: start, length: offset - start, line: startLine, column: startColumn, }) continue } if (char === '`') { advance() while (offset < text.length) { const current = advance() if (current === '\\') { if (offset < text.length) advance() continue } if (current === '`') break } continue } if (identifierStart.test(char)) { const start = offset const startLine = line const startColumn = column let value = advance() while (offset < text.length && identifierPart.test(text[offset]!)) value += advance() tokens.push({ kind: 'identifier', value, offset: start, length: offset - start, line: startLine, column: startColumn, }) continue } tokens.push({ kind: 'punctuation', value: char, offset, length: 1, line, column, }) advance() } return tokens } function decodeEscape(char: string): string { switch (char) { case 'n': return '\n' case 'r': return '\r' case 't': return '\t' default: return char } } function parseImports(tokens: readonly SourceToken[]): SourceImport[] { const imports: SourceImport[] = [] for (let index = 0; index < tokens.length; index++) { const token = tokens[index]! if (token.kind !== 'identifier' || (token.value !== 'import' && token.value !== 'export')) { continue } if (token.value === 'import' && tokens[index + 1]?.value === '(') { const specifier = tokens[index + 2] if (specifier?.kind === 'string') { imports.push({ specifier: specifier.value, token, bindings: new Map() }) } continue } const end = statementEnd(tokens, index + 1) let specifierIndex = -1 for (let cursor = index + 1; cursor < end; cursor++) { const candidate = tokens[cursor]! if (candidate.kind === 'string') specifierIndex = cursor } if (specifierIndex < 0) continue const specifier = tokens[specifierIndex]! const bindings = new Map() let namespace: string | undefined if (token.value === 'import') { const fromIndex = findToken(tokens, index + 1, specifierIndex, 'from') const bindingsEnd = fromIndex >= 0 ? fromIndex : specifierIndex const open = findToken(tokens, index + 1, bindingsEnd, '{') if (open >= 0) { const close = findMatching(tokens, open, '{', '}', bindingsEnd) if (close >= 0) parseNamedBindings(tokens, open + 1, close, bindings) } const star = findToken(tokens, index + 1, bindingsEnd, '*') if (star >= 0 && tokens[star + 1]?.value === 'as') namespace = tokens[star + 2]?.value } imports.push({ specifier: specifier.value, token, bindings, ...(namespace ? { namespace } : {}), }) index = Math.max(index, specifierIndex) } return imports } function statementEnd(tokens: readonly SourceToken[], start: number): number { let depth = 0 let sawSpecifier = false for (let index = start; index < tokens.length; index++) { const token = tokens[index]! if (token.value === ';' && depth === 0) return index if (token.value === '{' || token.value === '(' || token.value === '[') depth++ if (token.value === '}' || token.value === ')' || token.value === ']') depth-- if (token.kind === 'string' && depth === 0) sawSpecifier = true const next = tokens[index + 1] if (sawSpecifier && depth === 0 && next && next.line > token.line) return index + 1 } return tokens.length } function findToken( tokens: readonly SourceToken[], start: number, end: number, value: string, ): number { for (let index = start; index < end; index++) { if (tokens[index]?.value === value) return index } return -1 } function findMatching( tokens: readonly SourceToken[], start: number, open: string, close: string, end: number, ): number { let depth = 0 for (let index = start; index < end; index++) { if (tokens[index]?.value === open) depth++ if (tokens[index]?.value === close && --depth === 0) return index } return -1 } function parseNamedBindings( tokens: readonly SourceToken[], start: number, end: number, bindings: Map, ): void { let index = start while (index < end) { if (tokens[index]?.value === ',' || tokens[index]?.value === 'type') { index++ continue } const imported = tokens[index] if (!imported || imported.kind !== 'identifier') { index++ continue } if (tokens[index + 1]?.value === 'as' && tokens[index + 2]?.kind === 'identifier') { bindings.set(imported.value, tokens[index + 2]!.value) index += 3 continue } bindings.set(imported.value, imported.value) index++ } } export function importedCalls( file: SourceFile, modules: ReadonlySet, names: ReadonlySet, ): SourceToken[] { const locals = new Set() const namespaces = new Set() for (const sourceImport of file.imports) { if (!modules.has(sourceImport.specifier)) continue for (const name of names) { const local = sourceImport.bindings.get(name) if (local) locals.add(local) } if (sourceImport.namespace) namespaces.add(sourceImport.namespace) } const calls: SourceToken[] = [] for (let index = 0; index < file.tokens.length; index++) { const token = file.tokens[index]! if (token.kind !== 'identifier') continue if (locals.has(token.value) && callFollows(file.tokens, index)) { calls.push(token) continue } if ( namespaces.has(token.value) && file.tokens[index + 1]?.value === '.' && names.has(file.tokens[index + 2]?.value ?? '') && callFollows(file.tokens, index + 2) ) { calls.push(file.tokens[index + 2]!) } } return calls } function callFollows(tokens: readonly SourceToken[], index: number): boolean { const next = tokens[index + 1] if (next?.value === '(') return true if (next?.value !== '<') return false const close = findMatching(tokens, index + 1, '<', '>', tokens.length) return close >= 0 && tokens[close + 1]?.value === '(' }