@zapier/stubtree
Version:
CLI tool to generate ASCII tree of project structure with symbol stubs using universal-ctags
534 lines • 23.6 kB
JavaScript
;
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