Skip to content

Commit 220f1e1

Browse files
committed
feat: implement import discovery functionality and add related tests
1 parent 88ee75a commit 220f1e1

8 files changed

Lines changed: 453 additions & 45 deletions

File tree

src/compiler/import-discovery.ts

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
import { existsSync, readFileSync } from 'node:fs';
2+
import { dirname, resolve } from 'node:path';
3+
4+
import * as babel from '@babel/core';
5+
import traverse, { Binding, Hub, NodePath, Scope } from '@babel/traverse';
6+
import * as t from '@babel/types';
7+
8+
9+
// Cache for parsed files to avoid re-parsing
10+
const fileCache: Map<string, t.File> = new Map();
11+
12+
13+
export type BabelPlugins = NonNullable<NonNullable<babel.TransformOptions['parserOpts']>['plugins']>;
14+
15+
16+
// Types for our discovery results
17+
export interface ElementDefinition {
18+
type: 'custom-element' | 'import' | 'local-variable' | 'unknown';
19+
source?: string; // file path for imports
20+
originalName?: string; // for imports/re-exports
21+
callExpression?: t.CallExpression; // for toJSX calls
22+
}
23+
24+
25+
// Helper function to find the definition of a JSX element
26+
export function findElementDefinition(path: NodePath<t.JSXOpeningElement>): ElementDefinition {
27+
const elementName = path.node.name;
28+
29+
const hub = path.hub as Hub & { file: { opts: { filename: string; }; }; };
30+
31+
const currentFileName = hub.file.opts.filename;
32+
33+
// Only handle JSXIdentifier (not JSXMemberExpression or JSXNamespacedName)
34+
if (!t.isJSXIdentifier(elementName))
35+
return { type: 'unknown' };
36+
37+
const elementNameString = elementName.name;
38+
39+
//console.log('Tracing element:', elementNameString);
40+
41+
// Start tracing with the current file context
42+
return traceElementDefinition(elementNameString, path.scope, currentFileName);
43+
}
44+
45+
// Core recursive tracing function
46+
function traceElementDefinition(
47+
elementName: string,
48+
scope: Scope,
49+
currentFileName: string,
50+
visitedFiles: Set<string> = new Set(),
51+
): ElementDefinition {
52+
// Prevent infinite recursion
53+
if (visitedFiles.has(`${ currentFileName }:${ elementName }`))
54+
return { type: 'unknown' };
55+
56+
visitedFiles.add(`${ currentFileName }:${ elementName }`);
57+
58+
// Check if there's a binding for this identifier in the current scope
59+
const binding = scope.getBinding(elementName);
60+
61+
if (!binding) {
62+
//console.log('No binding found for:', elementName);
63+
64+
return { type: 'unknown' };
65+
}
66+
67+
//console.log('Binding kind:', binding.kind, 'type:', binding.path.type);
68+
69+
// Handle imports
70+
if (binding.kind === 'module' && t.isImportSpecifier(binding.path.node))
71+
return traceImport(binding, currentFileName, elementName, visitedFiles);
72+
73+
74+
// Handle local variables/constants
75+
if (binding.kind === 'const' || binding.kind === 'let' || binding.kind === 'var')
76+
return traceLocalVariable(binding, currentFileName, visitedFiles);
77+
78+
79+
return { type: 'unknown' };
80+
}
81+
82+
function traceImport(
83+
binding: Binding,
84+
currentFileName: string,
85+
elementName: string,
86+
visitedFiles: Set<string>,
87+
): ElementDefinition {
88+
const importDeclaration = binding.path.parent;
89+
90+
if (!t.isImportDeclaration(importDeclaration))
91+
return { type: 'unknown' };
92+
93+
const importSource = importDeclaration.source.value;
94+
const currentDir = dirname(currentFileName);
95+
const resolvedPath = resolve(currentDir, importSource);
96+
97+
//console.log('Tracing import from:', importSource);
98+
//console.log('Resolved to:', resolvedPath);
99+
100+
// Use cached parsing
101+
const ast = getOrParseFile(resolvedPath);
102+
if (!ast)
103+
return { type: 'unknown' };
104+
105+
106+
let result: ElementDefinition = { type: 'unknown' };
107+
108+
traverse(ast, {
109+
Program(programPath) {
110+
// First try to find a local binding (normal export)
111+
const localBinding = programPath.scope.getBinding(elementName);
112+
113+
if (localBinding) {
114+
//console.log('Found local binding in imported file');
115+
result = traceElementDefinition(elementName, programPath.scope, resolvedPath, visitedFiles);
116+
117+
return;
118+
}
119+
120+
// If no local binding found, check for re-exports
121+
//console.log('No local binding, checking for re-exports...');
122+
result = checkForReExports(programPath, elementName, resolvedPath, visitedFiles);
123+
},
124+
});
125+
126+
return result;
127+
}
128+
129+
function traceLocalVariable(
130+
binding: Binding,
131+
currentFileName: string,
132+
visitedFiles: Set<string>,
133+
): ElementDefinition {
134+
//console.log('Tracing local variable:', binding.kind, binding.path.type);
135+
136+
// Check if it's a variable declarator (const/let/var)
137+
if (t.isVariableDeclarator(binding.path.node)) {
138+
const declarator = binding.path.node;
139+
140+
// Check if it's assigned to a call expression
141+
if (declarator.init && t.isCallExpression(declarator.init)) {
142+
const callExpr = declarator.init;
143+
144+
// Check if it's a toJSX call
145+
if (t.isIdentifier(callExpr.callee) && callExpr.callee.name === 'toJSX') {
146+
return {
147+
type: 'custom-element',
148+
source: currentFileName,
149+
callExpression: callExpr,
150+
};
151+
}
152+
153+
// Could be assigned to another function call - trace that too
154+
//console.log('Local variable assigned to call expression:', callExpr.callee);
155+
}
156+
157+
// Check if it's assigned to an identifier (another variable)
158+
if (declarator.init && t.isIdentifier(declarator.init)) {
159+
const assignedIdentifier = declarator.init.name;
160+
//console.log('Local variable assigned to identifier:', assignedIdentifier);
161+
162+
// Recursively trace this identifier in the same scope
163+
return traceElementDefinition(assignedIdentifier, binding.path.scope, currentFileName, visitedFiles);
164+
}
165+
}
166+
167+
return { type: 'local-variable' };
168+
}
169+
170+
function checkForReExports(
171+
programPath: babel.NodePath<babel.types.Program>,
172+
elementName: string,
173+
currentFileName: string,
174+
visitedFiles: Set<string>,
175+
): ElementDefinition {
176+
let result: ElementDefinition = { type: 'unknown' };
177+
178+
// Check all export declarations for re-exports
179+
programPath.traverse({
180+
ExportNamedDeclaration(exportPath) {
181+
const node = exportPath.node;
182+
183+
if (!node.source || !node.specifiers)
184+
return; // Skip if no source or specifiers
185+
186+
// Handle re-exports: export { X } from './file'
187+
//console.log('Found re-export from:', node.source.value);
188+
189+
for (const specifier of node.specifiers) {
190+
if (!t.isExportSpecifier(specifier))
191+
continue; // Only handle export specifiers
192+
193+
const exportedName = t.isIdentifier(specifier.exported)
194+
? specifier.exported.name
195+
: specifier.exported.value;
196+
197+
// Check if this re-export matches our element name
198+
if (exportedName !== elementName)
199+
continue;
200+
201+
const originalName = specifier.local.name;
202+
//console.log(`Found re-export: ${ originalName } as ${ exportedName }`);
203+
204+
// Resolve and trace the re-exported file
205+
const reExportSource = node.source.value;
206+
const currentDir = dirname(currentFileName);
207+
const resolvedPath = resolve(currentDir, reExportSource);
208+
209+
if (!existsSync(resolvedPath))
210+
continue; // Skip if file doesn't exist
211+
212+
//console.log('Tracing re-export to:', resolvedPath);
213+
result = traceReExport(originalName, resolvedPath, visitedFiles);
214+
215+
// Stop traversing once we find the match
216+
return exportPath.stop();
217+
}
218+
},
219+
});
220+
221+
return result;
222+
}
223+
224+
function traceReExport(
225+
elementName: string,
226+
filePath: string,
227+
visitedFiles: Set<string>,
228+
): ElementDefinition {
229+
// Use cached parsing
230+
const ast = getOrParseFile(filePath);
231+
if (!ast)
232+
return { type: 'unknown' };
233+
234+
235+
let result: ElementDefinition = { type: 'unknown' };
236+
237+
traverse(ast, {
238+
Program(programPath) {
239+
// Continue tracing in the re-exported file
240+
result = traceElementDefinition(elementName, programPath.scope, filePath, visitedFiles);
241+
},
242+
});
243+
244+
return result;
245+
}
246+
247+
248+
// Helper function to get or parse a file with caching
249+
function getOrParseFile(filePath: string): t.File | undefined {
250+
// Check cache first
251+
if (fileCache.has(filePath)) {
252+
//console.log('Using cached AST for:', filePath);
253+
254+
return fileCache.get(filePath)!;
255+
}
256+
257+
// File not in cache, parse it
258+
if (!existsSync(filePath))
259+
return;
260+
261+
const fileContent = readFileSync(filePath, 'utf-8');
262+
263+
try {
264+
const ast = babel.parseSync(fileContent, {
265+
filename: filePath,
266+
parserOpts: {
267+
plugins: [ 'jsx', 'typescript' ] satisfies BabelPlugins,
268+
},
269+
});
270+
271+
if (ast) {
272+
//console.log('Parsed and cached:', filePath);
273+
fileCache.set(filePath, ast);
274+
275+
return ast;
276+
}
277+
}
278+
catch (error) {
279+
console.log('Failed to parse file:', filePath, error);
280+
}
281+
}

test/import-discovery.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
2+
import * as babel from '@babel/core';
3+
import { test } from 'vitest';
4+
5+
import { type ElementDefinition, findElementDefinition } from '../src/compiler/import-discovery.ts';
6+
import { type BabelPlugins } from './utils.ts';
7+
8+
9+
const getOpts = (result: { definition: ElementDefinition; }): babel.TransformOptions => {
10+
return ({
11+
root: '.',
12+
filename: import.meta.filename,
13+
sourceFileName: import.meta.filename,
14+
plugins: [
15+
{
16+
visitor: {
17+
JSXOpeningElement(path) {
18+
result.definition = findElementDefinition(path);
19+
},
20+
},
21+
},
22+
],
23+
ast: false,
24+
sourceMaps: true,
25+
configFile: false,
26+
babelrc: false,
27+
parserOpts: {
28+
plugins: [ 'jsx', 'typescript' ] satisfies BabelPlugins,
29+
},
30+
});
31+
};
32+
33+
test('can discover custom elements', async ({ expect }) => {
34+
const source = `
35+
import { DiscoveryTest } from './import-discovery/import-discovery.ts';
36+
37+
class LocalClass extends HTMLElement {
38+
static tagName = 'local-class';
39+
}
40+
41+
const template = (
42+
<DiscoveryTest />
43+
);
44+
`;
45+
46+
const result: { definition: ElementDefinition; } = { definition: { type: 'unknown' } };
47+
(await babel.transformAsync(source, getOpts(result)))?.code;
48+
49+
console.log(result.definition);
50+
51+
52+
// The console.log outputs will show you the traversal results
53+
//console.log('Final transformed code:', code);
54+
});
55+
56+
test('can discover reassigned and re-exported toJSX elements', async ({ expect }) => {
57+
// This test uses real files created in the complex-scenario directory:
58+
// - actual-component.ts: contains the toJSX call
59+
// - reassign-file.ts: imports and reassigns the element
60+
// - barrel-export.ts: re-exports with a new name
61+
62+
// Main test source that imports the final element
63+
const source = `
64+
import { FinalElement } from './import-discovery/barrel-export.ts';
65+
66+
const template = (
67+
<FinalElement />
68+
);
69+
`;
70+
71+
const result: { definition: ElementDefinition; } = { definition: { type: 'unknown' } };
72+
(await babel.transformAsync(source, getOpts(result)))?.code;
73+
74+
console.log(result.definition);
75+
76+
77+
// The console.log outputs will show the full tracing chain:
78+
// FinalElement -> ReassignedElement -> ActualElement -> toJSX(MyActualComponent)
79+
console.log('Test completed - check console output for tracing results');
80+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { type ToJSX, toJSX } from '../../src/utils.ts';
2+
3+
4+
class MyActualComponent extends HTMLElement {
5+
6+
static tagName = 'my-actual-component';
7+
8+
constructor() {
9+
super();
10+
}
11+
12+
connectedCallback(): void {
13+
this.innerHTML = '<p>My Actual Component</p>';
14+
}
15+
16+
}
17+
18+
export const ActualElement: ToJSX<MyActualComponent> = toJSX(MyActualComponent);
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { ReassignedElement as FinalElement } from './reassign-file.ts';

0 commit comments

Comments
 (0)