UNPKG

@zapier/stubtree

Version:

CLI tool to generate ASCII tree of project structure with symbol stubs using universal-ctags

534 lines 23.6 kB
#!/usr/bin/env node "use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const commander_1 = require("commander"); const child_process_1 = require("child_process"); const promises_1 = require("fs/promises"); const path_1 = require("path"); const readline_1 = require("readline"); const chalk_1 = __importDefault(require("chalk")); const tty_1 = require("tty"); class Stubtree { options; maxDepth; langExtensions; useColor; skipCtagsExtensions; constructor(options) { this.options = options; this.maxDepth = options.depth ? parseInt(options.depth, 10) : Infinity; this.langExtensions = options.lang ? new Set(options.lang.split(',').map(ext => `.${ext}`)) : null; this.useColor = !options.json && (0, tty_1.isatty)(process.stdout.fd); // File extensions to show in tree but skip ctags parsing this.skipCtagsExtensions = new Set([ '.json', '.jsonc', '.json5', '.yml', '.yaml', '.toml', '.ini', '.cfg', '.conf', '.config', '.xml', '.html', '.htm', '.md', '.markdown', '.rst', '.txt', '.gitignore', '.dockerignore', '.eslintignore', '.prettierignore', '.env', '.env.local', '.env.development', '.env.production', '.env.example', '.sh', '.bash', '.zsh', '.fish', '.lock', '.mjs', '.cjs', // Module JS files often have config 'Makefile', 'makefile', // Makefiles '.mk' ]); } async run() { try { const tags = await this.runCtags(); const tree = await this.buildTree(tags); if (this.options.json) { console.log(JSON.stringify(tree, null, 2)); } else { this.renderTree(tree); } } catch (error) { console.error(chalk_1.default.red(`Error: ${error instanceof Error ? error.message : error}`)); process.exit(1); } } async runCtags() { return new Promise((resolve, reject) => { const tags = []; const args = [ '--output-format=json', '--fields=+neKStr', // Added 'r' for pattern '--extras=+q', '--sort=no', '-R' ]; // Add exclusions for file extensions we want to skip for (const ext of this.skipCtagsExtensions) { args.push(`--exclude=*${ext}`); } // Add exclusions for directories const excludeDirs = [ 'node_modules', 'vendor', 'venv', '.git', 'dist', 'build', 'coverage', '.next', '.nuxt', '__pycache__' ]; for (const dir of excludeDirs) { args.push(`--exclude=${dir}`); } args.push(this.options.root); const ctags = (0, child_process_1.spawn)('ctags', args); const rl = (0, readline_1.createInterface)({ input: ctags.stdout, crlfDelay: Infinity }); rl.on('line', (line) => { try { const data = JSON.parse(line); const path = (0, path_1.relative)(this.options.root, data.path); if (this.shouldIncludeFile(path)) { const tag = { path, name: data.name, kind: data.kind, }; if (data.signature) { tag.signature = data.signature; } if (data.scope) { tag.scope = data.scope; } if (data.scopeKind) { tag.scopeKind = data.scopeKind; } if (data.typeref) { // Extract clean type from typeref (remove 'typename:' prefix) tag.typeRef = data.typeref.replace(/^typename:/, ''); } // Extract additional information from pattern if (data.pattern) { if (tag.kind === 'property' || tag.kind === 'constant' || tag.kind === 'variable') { // Extract type annotation for TypeScript/Python const typeMatch = data.pattern.match(/:\s*([^;=]+)[;=]/); if (typeMatch) { tag.typeRef = typeMatch[1].trim(); } } else if (tag.kind === 'function' || tag.kind === 'method' || tag.kind === 'member') { // Extract full function signature let sigMatch = data.pattern.match(/(?:async\s+)?(?:function\s+)?\w+(\([^)]*\))(?:\s*:\s*([^{]+))?/); if (!sigMatch) { // Try arrow function pattern sigMatch = data.pattern.match(/\w+\s*=\s*(?:async\s+)?(\([^)]*\))\s*(?::\s*([^=]+))?\s*=>/); } if (!sigMatch) { // Try Python function pattern sigMatch = data.pattern.match(/(?:async\s+)?def\s+\w+(\([^)]*\))(?:\s*->\s*([^:]+))?/); } if (sigMatch) { tag.signature = sigMatch[1].trim(); if (sigMatch[2]) { tag.typeRef = sigMatch[2].trim(); } // Check if async if (data.pattern.includes('async ')) { tag.signature = 'async ' + tag.signature; } } } else if (tag.kind === 'class') { // Extract base class from pattern const baseMatch = data.pattern.match(/class\s+\w+\(([^)]+)\)/); if (baseMatch) { tag.signature = `(${baseMatch[1].trim()})`; } } } // Skip internal variables (constants inside functions/methods) if (tag.scopeKind === 'method' || tag.scopeKind === 'function') { if (tag.kind === 'constant' || tag.kind === 'variable') { return; // Skip internal implementation details } } tags.push(tag); } } catch (err) { // Skip invalid JSON lines } }); ctags.stderr.on('data', (_data) => { // Ignore stderr output from ctags }); ctags.on('error', (err) => { if (err.message.includes('ENOENT')) { reject(new Error('ctags not found. Please install universal-ctags:\n\n' + ' macOS: brew install universal-ctags\n' + ' Ubuntu/Debian: sudo apt-get install universal-ctags\n' + ' Other: See https://github.com/universal-ctags/ctags\n\n' + 'Note: Ensure ctags is in your PATH after installation.')); } else { reject(err); } }); ctags.on('close', (code) => { if (code !== 0 && tags.length === 0) { reject(new Error(`ctags exited with code ${code}`)); } else { resolve(tags); } }); }); } shouldIncludeFile(path) { if (!this.langExtensions) return true; const ext = (0, path_1.extname)(path); return this.langExtensions.has(ext); } organizeTags(tags) { // Only include top-level items (no scope or class/interface scope) const rootTags = []; const seenNames = new Set(); for (const tag of tags) { // Skip if this is a scoped property/method if (tag.scope && tag.scope.indexOf('.') === -1) { continue; // Skip members, they'll be handled separately } // Skip duplicates (e.g., both Tag and Tag.name for same interface) const uniqueKey = `${tag.name}-${tag.kind}`; if (seenNames.has(uniqueKey)) { continue; } seenNames.add(uniqueKey); // Only include if no scope or if scope has dots (nested scope) if (!tag.scope) { // Include all top-level items except common variable names if (tag.kind === 'constant' || tag.kind === 'variable') { // Include constants that look like configuration (all caps) if (tag.name === tag.name.toUpperCase() && tag.name.length > 1) { rootTags.push(tag); } // Skip common variable names like 'logger', 'app', etc. } else { // Include all other tags (classes, functions, etc.) rootTags.push(tag); } } } // Return tags in their original order (as encountered in the file) return rootTags; } async buildTree(tags) { const root = { name: '.', type: 'directory', children: [] }; // Group and organize tags by file and scope const tagsByFile = new Map(); for (const tag of tags) { if (!tagsByFile.has(tag.path)) { tagsByFile.set(tag.path, []); } tagsByFile.get(tag.path).push(tag); } // Keep original tags for member lookup, but use organized tags for display const originalTagsByFile = new Map(tagsByFile); // Sort and organize tags within each file for top-level display for (const [path, fileTags] of tagsByFile) { tagsByFile.set(path, this.organizeTags(fileTags)); } await this.walkDirectory(this.options.root, root, tagsByFile, originalTagsByFile, 0); return root; } async walkDirectory(dirPath, node, tagsByFile, originalTagsByFile, depth) { if (depth >= this.maxDepth) return; const entries = await (0, promises_1.readdir)(dirPath); const children = []; for (const entry of entries) { const fullPath = (0, path_1.join)(dirPath, entry); const relativePath = (0, path_1.relative)(this.options.root, fullPath); let stats; try { stats = await (0, promises_1.stat)(fullPath); } catch (error) { // Skip files we can't stat (e.g., broken symlinks) continue; } if (stats.isDirectory()) { if (!this.shouldIgnoreDirectory(entry)) { const childNode = { name: entry, type: 'directory', children: [] }; await this.walkDirectory(fullPath, childNode, tagsByFile, originalTagsByFile, depth + 1); if (childNode.children && childNode.children.length > 0) { children.push(childNode); } } } else if (stats.isFile()) { if (!this.shouldIgnoreFile(entry) && this.shouldIncludeFile(relativePath)) { const fileTags = tagsByFile.get(relativePath) || []; const originalFileTags = originalTagsByFile.get(relativePath) || []; const ext = (0, path_1.extname)(entry); // Show files that match language filter or have tags or are skipped from ctags if (fileTags.length > 0 || !this.langExtensions || this.skipCtagsExtensions.has(ext)) { children.push({ name: entry, type: 'file', tags: fileTags, // Store original tags for member lookup originalTags: originalFileTags }); } } } } node.children = children; } shouldIgnoreFile(name) { const ignorePatterns = [ // Lock files 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'npm-shrinkwrap.json', 'Gemfile.lock', 'Cargo.lock', 'poetry.lock', 'composer.lock', 'go.sum', 'Gopkg.lock', 'pdm.lock', 'uv.lock', // Generated files '*.min.js', '*.min.css', '*.map', '*.map.js', // Binary files '*.pyc', '*.pyo', '*.pyd', '*.so', '*.dylib', '*.dll', '*.exe', '*.class', '*.jar', '*.war', '*.ear', // Archives '*.zip', '*.tar', '*.tar.gz', '*.tgz', '*.rar', '*.7z', // Media files '*.jpg', '*.jpeg', '*.png', '*.gif', '*.ico', '*.svg', '*.webp', '*.mp3', '*.mp4', '*.avi', '*.mov', '*.wmv', '*.flv', '*.pdf', '*.doc', '*.docx', '*.xls', '*.xlsx', '*.ppt', '*.pptx', // Database files '*.db', '*.sqlite', '*.sqlite3', // OS files '.DS_Store', 'Thumbs.db', 'desktop.ini', // IDE files '*.swp', '*.swo', '*~', '*.bak', // Other 'LICENSE', 'LICENSE.txt', 'LICENSE.md', 'COPYING', '*.log', '*.pid', // Config files '.editorconfig', '.prettierrc', '.prettierrc.*', '.eslintrc', '.eslintrc.*', '.pylintrc', '.babelrc', '.babelrc.*', 'jest.config.*', 'vitest.config.*', 'vite.config.*', 'webpack.config.*', 'rollup.config.*', 'tsconfig.tsbuildinfo', '.travis.yml', '.circleci', 'appveyor.yml', '.github', '.gitlab-ci.yml', 'azure-pipelines.yml', // Tool-specific '.tool-versions', '.nvmrc', '.rvmrc', '.python-version', '.envrc', '.direnv', // Project metadata '.cookiecutter.yml', 'opslevel.yml', '.all-contributorsrc', // Test/Coverage '*.spec.js', '*.spec.ts', '*.test.js', '*.test.ts', 'coverage.xml', 'coverage.json' ]; // Check exact matches if (ignorePatterns.includes(name)) return true; // Check glob patterns for (const pattern of ignorePatterns) { if (pattern.includes('*')) { const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$'); if (regex.test(name)) return true; } } return false; } shouldIgnoreDirectory(name) { const ignoreDirs = [ // Version control '.git', '.svn', '.hg', '.bzr', // Dependencies 'node_modules', 'bower_components', 'jspm_packages', 'vendor', 'vendors', 'venv', 'env', '.env', 'virtualenv', '.venv', 'site-packages', // Build outputs 'dist', 'build', 'out', 'output', 'target', '.next', '.nuxt', '.output', '.svelte-kit', // Cache directories '.cache', '.stubtree-cache', '.parcel-cache', '.sass-cache', '__pycache__', '.pytest_cache', '.mypy_cache', '.tox', '.nox', // IDE '.idea', '.vscode', '.vs', // Coverage 'coverage', '.nyc_output', 'htmlcov', // Temporary 'tmp', 'temp', '.tmp', '.temp', // Mobile 'Pods', '.gradle', 'gradle', // Documentation builds '_build', '_site', 'site', // Package managers '.pnpm', '.yarn', // Other 'logs', '.log' ]; return ignoreDirs.includes(name) || (name.startsWith('.') && name !== '.'); } renderTree(node, prefix = '', isLast = true) { const connector = isLast ? '└── ' : '├── '; const name = node.name === '.' ? this.options.root : node.name; if (prefix === '') { console.log(this.colorize(name + '/', 'directory')); } else { console.log(prefix + connector + this.colorize(node.type === 'directory' ? name + '/' : name, node.type)); } const extension = isLast ? ' ' : '│ '; const newPrefix = prefix + extension; if (node.tags && node.tags.length > 0) { // Group tags by their parent scope const membersByParent = new Map(); const topLevelTags = []; // Use original tags for member lookup if available const allTags = node.originalTags || node.tags; for (const tag of node.tags) { if (!tag.scope) { topLevelTags.push(tag); } } // Collect members for each parent from all tags for (const tag of allTags) { if (tag.scope && !tag.scope.includes('.')) { // Collect properties, methods, and members (Python) as members if (tag.kind === 'property' || tag.kind === 'method' || tag.kind === 'member' || tag.kind === 'variable') { // Skip if this is a fully qualified name (e.g., Class.method) if (tag.name.includes('.')) continue; if (!membersByParent.has(tag.scope)) { membersByParent.set(tag.scope, []); } membersByParent.get(tag.scope).push(tag); } } } // Render top-level tags with their members topLevelTags.forEach((tag, index) => { const members = membersByParent.get(tag.name) || []; const hasMembers = members.length > 0; const isLastTag = index === topLevelTags.length - 1 && (!node.children || node.children.length === 0); const tagConnector = isLastTag ? '└── ' : '├── '; const tagStr = this.formatTag(tag); console.log(newPrefix + tagConnector + this.colorize(tagStr, 'tag')); // Render members if this is a class/interface if (hasMembers) { const memberPrefix = newPrefix + (isLastTag ? ' ' : '│ '); members.forEach((member, memberIndex) => { const isLastMember = memberIndex === members.length - 1; const memberConnector = isLastMember ? '└── ' : '├── '; const memberStr = this.formatTag(member); console.log(memberPrefix + memberConnector + this.colorize(memberStr, 'tag')); }); } }); } if (node.children) { node.children.forEach((child, index) => { const isLastChild = index === node.children.length - 1; this.renderTree(child, newPrefix, isLastChild); }); } } formatTag(tag) { let result = ''; // Add kind prefix for clarity if (tag.kind === 'interface' || tag.kind === 'enum' || tag.kind === 'type') { result = `${tag.kind} ${tag.name}`; } else if (tag.kind === 'alias') { result = `type ${tag.name}`; } else if (tag.kind === 'class') { result = `class ${tag.name}`; // Add base class if available if (tag.signature) { result += tag.signature; } } else if (tag.kind === 'function' || tag.kind === 'method' || tag.kind === 'member') { // Check if async function from signature const isAsync = tag.signature && tag.signature.startsWith('async '); if (isAsync && tag.signature) { result = `async ${tag.name}`; // Remove async from signature since we're showing it in the name tag = { ...tag, signature: tag.signature.replace(/^async\s+/, '') }; } else { result = tag.name; } } else if (tag.kind === 'constant' || tag.kind === 'variable') { // Show constants/variables with their name result = tag.name; } else { result = tag.name; } // Add type information for properties, constants, and variables if ((tag.kind === 'property' || tag.kind === 'constant' || tag.kind === 'variable') && tag.typeRef) { result += `: ${tag.typeRef}`; } // Add signature for functions/methods (but not for classes) if (tag.kind !== 'class' && tag.signature) { result += tag.signature; } else if ((tag.kind === 'function' || tag.kind === 'method' || tag.kind === 'member') && !tag.signature) { result += '()'; } // Add return type if available (skip $/ which means unknown) if ((tag.kind === 'function' || tag.kind === 'method' || tag.kind === 'member') && tag.typeRef && tag.typeRef !== '$/') { result += ` -> ${tag.typeRef}`; } return result; } colorize(text, type) { if (!this.useColor) return text; switch (type) { case 'directory': return chalk_1.default.blue.bold(text); case 'file': return chalk_1.default.green(text); case 'tag': return chalk_1.default.gray(text); default: return text; } } } const program = new commander_1.Command(); program .name('stubtree') .description('Generate ASCII tree of project structure with symbol stubs using universal-ctags') .version('0.1.0') .option('--root <dir>', 'root directory to scan', process.cwd()) .option('--lang <globs>', 'comma-separated file extensions to include (e.g., py,ts,tsx)') .option('--depth <n>', 'maximum directory depth to traverse') .option('--json', 'emit raw JSON instead of ASCII tree', false) .action(async (options) => { const stubtree = new Stubtree(options); await stubtree.run(); }); program.parse(process.argv); //# sourceMappingURL=index.js.map