diff --git a/README.md b/README.md index b807b32..0110f52 100644 --- a/README.md +++ b/README.md @@ -186,9 +186,10 @@ Any updates you make to your files will be automatically processed and mirrored 1. Fork the [project repo](https://github.com/hb1998/react-component-tree). 2. Open the `react-component-tree/extension` folder in your VS Code IDE. 3. Open `extension/src/extension.ts` -4. With the `extension` folder as your pwd, run this command: `npm run watch`. -5. From the menu, click Run - Start Debugging (or press F5), and select VS Code Extension Development from the command palette dropdown. An extension development host will open in a new window. -6. Click the React Component Tree icon on the extension development host window sidebar. To refresh the extension development host, use `Ctrl+R` (or `Cmd+R` on Mac). +4. Run `npm i` +5. With the `extension` folder as your pwd, run this command: `npm run watch`. +6. From the menu, click Run - Start Debugging (or press F5), and select VS Code Extension Development from the command palette dropdown. An extension development host will open in a new window. +7. Click the React Component Tree icon on the extension development host window sidebar. To refresh the extension development host, use `Ctrl+R` (or `Cmd+R` on Mac).
diff --git a/extension/package.json b/extension/package.json index cf030b3..b19f625 100644 --- a/extension/package.json +++ b/extension/package.json @@ -5,7 +5,7 @@ "repository": "https://github.com/hb1998/react-component-tree", "icon": "media/logo-128px.png", "publisher": "HabeebArul", - "version": "1.1.1", + "version": "1.2.0", "engines": { "vscode": "^1.60.0" }, diff --git a/extension/src/SaplingParser.ts b/extension/src/SaplingParser.ts index 244410f..cecbb5d 100644 --- a/extension/src/SaplingParser.ts +++ b/extension/src/SaplingParser.ts @@ -3,13 +3,13 @@ import * as path from 'path'; import { parse as babelParse } from '@babel/parser'; import { - ImportDeclaration, isArrayPattern, isCallExpression, isIdentifier, isImport, - isImportDeclaration, isImportDefaultSpecifier, isImportNamespaceSpecifier, isImportSpecifier, - isObjectPattern, isObjectProperty, isStringLiteral, isVariableDeclaration, Node as ASTNode, - VariableDeclaration + ImportDeclaration, FunctionDeclaration, isArrayPattern, isCallExpression, isIdentifier, isImport, + isImportDeclaration, isExportDeclaration, isExportAllDeclaration, isExportNamedDeclaration, isFunctionDeclaration, isImportDefaultSpecifier, isImportNamespaceSpecifier, isImportSpecifier, + isObjectPattern, isObjectProperty, isStringLiteral, isVariableDeclaration, isTSTypeAnnotation, isArrowFunctionExpression, Node as ASTNode, + VariableDeclaration, ExportAllDeclaration, ExportNamedDeclaration, ExportDefaultDeclaration,isExportDefaultDeclaration, isTSTypeAliasDeclaration, isTSTypeLiteral, isTSPropertySignature, isVariableDeclarator, ArrowFunctionExpression, is } from '@babel/types'; -import { ImportData, Token, Tree } from './types'; +import { ExportData, ImportData, Token, Tree } from './types'; export class SaplingParser { /** Public method to generate component tree based on entry file or input node. @@ -23,6 +23,31 @@ export class SaplingParser { public static parse(input: unknown): unknown { if (typeof input === 'string') { const entryFile = ParserHelpers.processFilePath(input); + let baseFilePath = path.dirname(entryFile); + const aliases = {}; + for(let i = 0; i < 10; i++) { + const fileArray = fs.readdirSync(baseFilePath); + if(fileArray.includes('tsconfig.json')){ + const tsConfigCompilerOptions = JSON.parse(fs.readFileSync(path.join(baseFilePath, 'tsconfig.json'), 'utf-8').split('\n').filter((line)=>{ + return !line.includes('//'); + }).join('\n')).compilerOptions; + if(tsConfigCompilerOptions.baseUrl){ + baseFilePath = path.join(baseFilePath, tsConfigCompilerOptions.baseUrl); + } + if(tsConfigCompilerOptions.paths){ + for(const [key, value] of Object.entries(tsConfigCompilerOptions.paths as Record)){ + if(value.length > 0){ + aliases[key] = value[0]; + } + } + } + break; + } + else if(fileArray.includes('package.json')){ + break; + } + baseFilePath = path.join(baseFilePath, '..'); + } // Create root Tree node const root = new Tree({ name: path.basename(entryFile).replace(/\.[jt]sx?$/, ''), @@ -30,6 +55,8 @@ export class SaplingParser { filePath: entryFile, importPath: '/', // this.entryFile here breaks windows file path on root e.g. C:\\ is detected as third party parent: null, + aliases, + projectBaseURL: baseFilePath }); ASTParser.parser(root); return root; @@ -81,7 +108,7 @@ const ASTParser = { parser(root: Tree): void { const recurse = (componentTree: Tree): void => { // If import is a node module, do not parse any deeper - if (!['\\', '/', '.'].includes(componentTree.importPath[0])) { + if (!['\\', '/', '.'].includes(componentTree.importPath[0]) && !componentTree.aliases[componentTree.importPath]) { componentTree.set('thirdParty', true); if ( componentTree.fileName === 'react-router-dom' || @@ -157,9 +184,21 @@ const ASTParser = { } else { const moduleIdentifier = imports[astToken.value].importPath; const name = imports[astToken.value].importName; - const filePath = ParserHelpers.validateFilePath( - path.resolve(path.dirname(parent.filePath), moduleIdentifier) + let filePath = ParserHelpers.validateFilePath( + parent.aliases[moduleIdentifier] ? path.join(parent.projectBaseURL, parent.aliases[moduleIdentifier]) : path.resolve(path.dirname(parent.filePath), moduleIdentifier) ); + if(parent.aliases[moduleIdentifier] || ['\\', '/', '.'].includes(moduleIdentifier[0])){ + try{ + const barrelFileSearchResults = ASTParser.recursivelySearchBarrelFiles(filePath, name); + filePath = barrelFileSearchResults.filePath; + if(barrelFileSearchResults.props){ + Object.assign(props, barrelFileSearchResults.props); + } + } + catch(e){ + console.error('problem in recursivelySearchBarrelFiles:' + e); + } + }; // Add tree node to childNodes if one does not exist childNodes[astToken.value] = new Tree({ name, @@ -175,6 +214,107 @@ const ASTParser = { return childNodes; }, + recursivelySearchBarrelFiles(filePath: string, componentName: string, topBarrelFile: boolean = true): {filePath: string, props?: Record} { + const extensions = ['.tsx', '.ts', '.jsx', '.js']; + const barrelFileNames = extensions.map((ext) => `index${ext}`); + const fileName = filePath.substring(filePath.lastIndexOf('/') + 1); + const parent = filePath.substring(0, filePath.lastIndexOf('/')); + // If it does not have an extension, check for all possible extensions + if(!fs.existsSync(filePath)){ + if(fileName.lastIndexOf('.') === -1){ + for(const ext of extensions){ + if(fs.existsSync(path.join(parent, `${fileName}${ext}`))){ + return ASTParser.recursivelySearchBarrelFiles(path.join(parent, `${fileName}${ext}`), componentName, topBarrelFile); + } + } + } + } + + // If it is a directory, check for barrel files + if(fs.lstatSync(filePath).isDirectory()){ + const files = fs.readdirSync(filePath); + for(const barrelFileName of barrelFileNames){ + if(files.includes(barrelFileName)){ + return ASTParser.recursivelySearchBarrelFiles(path.join(filePath, barrelFileName), componentName, topBarrelFile); + } + } + } + else { + const ast = babelParse(fs.readFileSync(filePath, 'utf-8'), { + sourceType: 'module', + tokens: true, // default: false, tokens deprecated from babel v7 + plugins: ['jsx', 'typescript'], + // TODO: additional plugins to look into supporting for future releases + // 'importMeta': https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import.meta + // 'importAssertions': parses ImportAttributes type + // https://github.com/babel/babel/blob/main/packages/babel-parser/ast/spec.md#ImportAssertions + allowImportExportEverywhere: true, // enables parsing dynamic imports and exports in body + attachComment: false, // performance benefits + }); + const exportDataArray = ExportParser.parse(ast.program.body); + // if index file + if(barrelFileNames.includes(filePath.substring(filePath.lastIndexOf('/') + 1))){ + for(const exportData of exportDataArray){ + const componentFouldFilePath = ASTParser.recursivelySearchBarrelFiles(path.join(filePath,'..', exportData.exportPath), componentName, false); + if(componentFouldFilePath){ + return componentFouldFilePath; + } + } + // If file is not found in the barrel file, throw an error + if(topBarrelFile){ + // console.error('FILE NOT FOUND:', {filePath, componentName, exportDataArray}); + return {filePath}; + } + } + // We have a file with an extension + else { + if(exportDataArray.length > 0){ + let foundExportData: ExportData; + for(const exportData of exportDataArray){ + if(exportData.exportName === componentName){ + foundExportData = exportData; + } + } + const defaultIndex = exportDataArray.findIndex((exportData)=>{ + return exportData.default; + }); + if(defaultIndex !== -1 && topBarrelFile){ + foundExportData = exportDataArray[defaultIndex]; + } + if(foundExportData){ + if(foundExportData.declaration){ + return {filePath, props: DestructuredPropsParser.parse(foundExportData.declaration)}; + } + // If file has a default export that is an identifier + else if(foundExportData.exportName){ + const foundFn = ASTParser.findIdentifierReference(ast.program.body, foundExportData.exportName); + if(foundFn){ + return {filePath, props: DestructuredPropsParser.parse(foundFn)}; + } + } + return {filePath}; + } + } + } + } + }, + + findIdentifierReference(body: ASTNode[], identifier: string): ArrowFunctionExpression | FunctionDeclaration | undefined { + for(const node of body){ + if(isFunctionDeclaration(node) && node.id && node.id.name === identifier){ + return node; + } + if(isVariableDeclaration(node)){ + for(const declaration of node.declarations){ + if(isVariableDeclarator(declaration) && isIdentifier(declaration.id) && declaration.id.name === identifier && (isFunctionDeclaration(declaration.init) || isArrowFunctionExpression(declaration.init))){ + return declaration.init; + } + } + } + } + return undefined; + }, + // Finds JSX React Components in current file getJSXChildren( astTokens: Token[], @@ -441,8 +581,100 @@ const ImportParser = { }, }; +const ExportParser = { + parse(body: ASTNode[]): ExportData[] { + return body + .filter((astNode) => isExportDeclaration(astNode)) + .reduce((accumulator, declaration) => { + return [...accumulator, ...(isExportAllDeclaration(declaration) + ? [ExportParser.parseExportAllDeclaration(declaration)] + : isExportNamedDeclaration(declaration) ? ExportParser.parseExportNamedDeclaration(declaration) : isExportDefaultDeclaration(declaration) ? [ExportParser.parseExportDefaultDeclaration(declaration)] : [])]; + }, []); + }, + + parseExportDefaultDeclaration(declaration: ExportDefaultDeclaration): ExportData { + if(isFunctionDeclaration(declaration.declaration) || isArrowFunctionExpression(declaration.declaration)){ + return { + default: true, + declaration: declaration.declaration + }; + } + if(isIdentifier(declaration.declaration)){ + return { + default: true, + exportName: declaration.declaration.name + }; + } + return { + default: true + }; + }, + + parseExportAllDeclaration(declaration: ExportAllDeclaration): ExportData { + return { + exportPath: declaration.source.value, + }; + }, + + parseExportNamedDeclaration(declaration: ExportNamedDeclaration): ExportData[] { + if(isFunctionDeclaration(declaration.declaration)){ + return [{ + exportName: declaration.declaration.id.name, + declaration: declaration.declaration + }]; + } + if(isVariableDeclaration(declaration.declaration)){ + return declaration.declaration.declarations.map((subDeclaration): ExportData=>{ + if(isIdentifier(subDeclaration.id)){ + if(isFunctionDeclaration(subDeclaration.init) || isArrowFunctionExpression(subDeclaration.init)){ + return { + exportName: subDeclaration.id.name, + declaration: subDeclaration.init + }; + } + return { + exportName: subDeclaration.id.name + }; + } + throw new Error('Only Identifier exports implemented'); + }); + } + if(isTSTypeAliasDeclaration(declaration.declaration)){ + return []; + } + throw new Error('Only Function Declaration and Variable exports implemented'); + } +}; + +const DestructuredPropsParser = { + parse(fn: FunctionDeclaration | ArrowFunctionExpression): Record { + if(isFunctionDeclaration(fn) || isArrowFunctionExpression(fn)){ + if(isObjectPattern(fn.params[0])){ + return DestructuredPropsParser.arrayToObject(fn.params[0].properties.map((prop)=>{ + if(isObjectProperty(prop) && isIdentifier(prop.key)){ + return prop.key.name; + } + })); + } + else if(isIdentifier(fn.params[0]) && isTSTypeAnnotation(fn.params[0].typeAnnotation) && isTSTypeLiteral(fn.params[0].typeAnnotation.typeAnnotation)){ + return DestructuredPropsParser.arrayToObject(fn.params[0].typeAnnotation.typeAnnotation.members.map((member)=>{ + if(isTSPropertySignature(member) && isIdentifier(member.key)){ + return member.key.name; + } + })); + } + } + return {}; + }, + arrayToObject(props: string[]): Record { + return props.reduce((accumulator, prop) => { + accumulator[prop] = true; + return accumulator; + }, {}); + } +}; + // TODO: Follow import source paths and parse Export{Named,Default,All}Declarations // See: https://github.com/babel/babel/blob/main/packages/babel-parser/ast/spec.md#exports // Necessary for handling... -// barrel files, namespace imports, default import + namespace/named imports, require invocations/method calls, ... -const ExportParser = {}; \ No newline at end of file +// barrel files, namespace imports, default import + namespace/named imports, require invocations/method calls, ... \ No newline at end of file diff --git a/extension/src/SaplingTree.ts b/extension/src/SaplingTree.ts index 1260d20..eba2072 100644 --- a/extension/src/SaplingTree.ts +++ b/extension/src/SaplingTree.ts @@ -9,6 +9,7 @@ export class Tree implements IRawNode, INode { fileName: string; filePath: string; importPath: string; + aliases: Record; expanded: boolean; depth: number; count: number; @@ -19,6 +20,7 @@ export class Tree implements IRawNode, INode { parent: Tree; parentList: string[]; props: Record; + projectBaseURL: string | undefined; error: | '' | 'File not found.' @@ -41,6 +43,8 @@ export class Tree implements IRawNode, INode { this.parent = node?.parent; this.parentList = node?.parentList ?? []; this.props = node?.props ?? {}; + this.aliases = node?.aliases ?? node?.parent?.aliases ?? {}; + this.projectBaseURL = node?.projectBaseURL ?? node?.parent?.projectBaseURL; this.error = node?.error ?? ''; } @@ -236,11 +240,12 @@ export class Tree implements IRawNode, INode { * @returns Tree class object with all nested descendant nodes also of Tree class. */ public static deserialize(data: Tree): Tree { - const recurse = (node: Tree): Tree => - (new Tree({ + const recurse = (node: Tree): Tree =>{ + return new Tree({ ...node, children: node.children?.map((child) => recurse(child)), - })); + }); + }; return recurse(data); } } \ No newline at end of file diff --git a/extension/src/types/ExportData.ts b/extension/src/types/ExportData.ts new file mode 100644 index 0000000..246dd8b --- /dev/null +++ b/extension/src/types/ExportData.ts @@ -0,0 +1,8 @@ +import { FunctionDeclaration, VariableDeclarator, ArrowFunctionExpression } from "@babel/types"; + +export type ExportData = { + exportPath?: string; + exportName?: string; + declaration?: FunctionDeclaration | ArrowFunctionExpression; + default?: boolean; +}; \ No newline at end of file diff --git a/extension/src/types/index.ts b/extension/src/types/index.ts index e903e0a..ddaecf6 100644 --- a/extension/src/types/index.ts +++ b/extension/src/types/index.ts @@ -2,4 +2,5 @@ export * from '../SaplingTree'; export * from './ImportData'; export * from './TreeNode'; export * from './Token'; -export * from './StoreTypes'; \ No newline at end of file +export * from './StoreTypes'; +export * from './ExportData'; \ No newline at end of file diff --git a/extension/src/webviews/components/Tree/Tree.tsx b/extension/src/webviews/components/Tree/Tree.tsx index 5d5f6cb..43817cf 100644 --- a/extension/src/webviews/components/Tree/Tree.tsx +++ b/extension/src/webviews/components/Tree/Tree.tsx @@ -42,10 +42,10 @@ const Tree: React.FC = () => { if (node) { scrollToNode(node); } - }) + }); return () => { PubSub.unsubscribe(subscription); - } + }; }, []); const scrollToNode = (node: INode) => {