From 1745053b88a47eb2e247a550319ffcc1151c7ca4 Mon Sep 17 00:00:00 2001 From: Omar-Elwazeery <57108797+Omar-Elwazeery@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:28:39 +0200 Subject: [PATCH] Add Bash script support --- __tests__/extraction.test.ts | 75 +++++++++ __tests__/resolution.test.ts | 22 +++ __tests__/sync.test.ts | 32 ++++ src/extraction/grammars.ts | 24 +++ src/extraction/index.ts | 52 +++++-- src/extraction/languages/bash.ts | 243 ++++++++++++++++++++++++++++++ src/extraction/languages/index.ts | 2 + src/resolution/import-resolver.ts | 1 + src/types.ts | 1 + 9 files changed, 442 insertions(+), 10 deletions(-) create mode 100644 src/extraction/languages/bash.ts diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 8619d12d7..f971e1a85 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -47,6 +47,14 @@ describe('Language Detection', () => { expect(detectLanguage('main.py')).toBe('python'); }); + it('should detect Bash and shell scripts', () => { + expect(detectLanguage('scripts/deploy.sh')).toBe('bash'); + expect(detectLanguage('scripts/deploy.bash')).toBe('bash'); + expect(detectLanguage('test/integration.bats')).toBe('bash'); + expect(detectLanguage('bin/deploy', '#!/usr/bin/env bash\nset -euo pipefail\n')).toBe('bash'); + expect(detectLanguage('bin/run', '#!/bin/sh\necho ok\n')).toBe('bash'); + }); + it('should detect Go files', () => { expect(detectLanguage('main.go')).toBe('go'); }); @@ -184,6 +192,7 @@ class ENGINE_API UNetConnectionRepControl : public UObject describe('Language Support', () => { it('should report supported languages', () => { expect(isLanguageSupported('typescript')).toBe(true); + expect(isLanguageSupported('bash')).toBe(true); expect(isLanguageSupported('python')).toBe(true); expect(isLanguageSupported('go')).toBe(true); expect(isLanguageSupported('unknown')).toBe(false); @@ -193,6 +202,7 @@ describe('Language Support', () => { const languages = getSupportedLanguages(); expect(languages).toContain('typescript'); expect(languages).toContain('javascript'); + expect(languages).toContain('bash'); expect(languages).toContain('python'); expect(languages).toContain('go'); expect(languages).toContain('rust'); @@ -208,6 +218,71 @@ describe('Language Support', () => { }); }); +describe('Bash Extraction', () => { + it('should extract shell functions, variables, command calls, and sourced scripts', () => { + const code = `#!/usr/bin/env bash +set -euo pipefail + +readonly ROOT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)" +export IMAGE_TAG="latest" + +source ./env.sh + +log() { + printf '%s\n' "$1" +} + +deploy() { + local target="\${1:-dev}" + ./scripts/build.sh "$target" + bash ./scripts/migrate.sh + kubectl apply -f k8s/ + log "deployed" +} + +deploy "$@" +`; + + const result = extractFromSource('scripts/deploy.sh', code); + + expect(result.nodes.find((n) => n.kind === 'function' && n.name === 'log')).toBeDefined(); + expect(result.nodes.find((n) => n.kind === 'function' && n.name === 'deploy')).toBeDefined(); + expect(result.nodes.find((n) => n.kind === 'constant' && n.name === 'ROOT_DIR')).toBeDefined(); + expect(result.nodes.find((n) => n.kind === 'variable' && n.name === 'IMAGE_TAG')?.isExported).toBe(true); + expect(result.nodes.find((n) => n.kind === 'variable' && n.name === 'target')).toBeDefined(); + + const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName); + expect(calls).toContain('./scripts/build.sh'); + expect(calls).toContain('bash'); + expect(calls).toContain('kubectl'); + expect(calls).toContain('log'); + expect(calls).toContain('deploy'); + + const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name); + expect(imports).toEqual(['./env.sh', './scripts/build.sh', './scripts/migrate.sh']); + + const importRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'imports').map((r) => r.referenceName); + expect(importRefs).toEqual(['./env.sh', './scripts/build.sh', './scripts/migrate.sh']); + }); + + it('should include extensionless Bash shebang scripts in directory scans', () => { + const tempDir = createTempDir(); + try { + const binDir = path.join(tempDir, 'bin'); + fs.mkdirSync(binDir, { recursive: true }); + fs.writeFileSync(path.join(binDir, 'deploy'), '#!/usr/bin/env bash\ndeploy() { echo ok; }\n'); + fs.writeFileSync(path.join(binDir, 'notes'), 'plain text\n'); + + const files = scanDirectory(tempDir); + + expect(files).toContain('bin/deploy'); + expect(files).not.toContain('bin/notes'); + } finally { + cleanupTempDir(tempDir); + } + }); +}); + describe('Nix Extraction', () => { it('should distinguish Nix variable and function bindings', () => { const code = ` diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 4d440e6dc..9602d841d 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -567,6 +567,28 @@ describe('Resolution Module', () => { expect(result).toBe('src/helpers.ts'); }); + it('should resolve extensionless Bash imports to Bats scripts', () => { + const context: ResolutionContext = { + getNodesInFile: () => [], + getNodesByName: () => [], + getNodesByQualifiedName: () => [], + getNodesByKind: () => [], + fileExists: (p) => p === 'test/helpers/assertions.bats', + readFile: () => null, + getProjectRoot: () => '', + getAllFiles: () => ['test/helpers/assertions.bats'], + }; + + const result = resolveImportPath( + './helpers/assertions', + 'test/integration.bats', + 'bash', + context + ); + + expect(result).toBe('test/helpers/assertions.bats'); + }); + it('should extract JS/TS import mappings', () => { const content = ` import { foo } from './foo'; diff --git a/__tests__/sync.test.ts b/__tests__/sync.test.ts index 159ee3b05..18ad6d7db 100644 --- a/__tests__/sync.test.ts +++ b/__tests__/sync.test.ts @@ -14,6 +14,38 @@ import { execFileSync } from 'child_process'; import CodeGraph from '../src/index'; describe('Sync Module', () => { + describe('Bash sync support', () => { + let testDir: string; + let cg: CodeGraph; + + beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-sync-bash-')); + cg = CodeGraph.initSync(testDir); + }); + + afterEach(() => { + if (cg) { + cg.destroy(); + } + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('should sync newly added extensionless Bash shebang scripts', async () => { + const binDir = path.join(testDir, 'bin'); + fs.mkdirSync(binDir, { recursive: true }); + fs.writeFileSync(path.join(binDir, 'deploy'), '#!/usr/bin/env bash\ndeploy() {\n echo ok\n}\n'); + + const result = await cg.sync(); + const nodes = cg.getNodesInFile('bin/deploy'); + + expect(result.filesAdded).toBe(1); + expect(cg.getFile('bin/deploy')?.language).toBe('bash'); + expect(nodes.some((n) => n.kind === 'function' && n.name === 'deploy')).toBe(true); + }); + }); + describe('Sync Functionality', () => { let testDir: string; let cg: CodeGraph; diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index 5a1b9ed42..acce4d56f 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -21,6 +21,7 @@ const WASM_GRAMMAR_FILES: Record = { tsx: 'tree-sitter-tsx.wasm', javascript: 'tree-sitter-javascript.wasm', jsx: 'tree-sitter-javascript.wasm', + bash: 'tree-sitter-bash.wasm', python: 'tree-sitter-python.wasm', go: 'tree-sitter-go.wasm', rust: 'tree-sitter-rust.wasm', @@ -71,6 +72,9 @@ export const EXTENSION_MAP: Record = { '.xsjs': 'javascript', '.xsjslib': 'javascript', '.jsx': 'jsx', + '.sh': 'bash', + '.bash': 'bash', + '.bats': 'bash', '.py': 'python', '.pyw': 'python', '.go': 'go', @@ -213,6 +217,24 @@ export function isErlangAppFile(filePath: string): boolean { return /\.app(?:\.src)?$/i.test(filePath); } +/** + * Extensionless executable scripts can still be Bash. Keep this deliberately + * narrow so arbitrary extensionless docs/binaries are not treated as source by + * path alone; callers that have content available should pair it with + * `hasBashShebang`. + */ +export function isPotentialBashShebangPath(filePath: string): boolean { + const base = path.basename(filePath); + return base.length > 0 && !base.includes('.'); +} + +/** True when the first line names bash or POSIX sh as the interpreter. */ +export function hasBashShebang(source: string): boolean { + const firstNewline = source.indexOf('\n'); + const firstLine = (firstNewline >= 0 ? source.slice(0, firstNewline) : source).trim(); + return /^#!.*(?:^|[\/\s])(?:bash|sh)(?:\s|$)/.test(firstLine); +} + /** * Play Framework routes file: the extensionless `conf/routes` (and included * `conf/*.routes`). No grammar — route extraction is done by the Play framework @@ -377,6 +399,7 @@ export function detectLanguage(filePath: string, source?: string, overrides?: Re // OTP `.app`/`.app.src` resource files — Erlang terms the grammar parses as // top-level expressions (last-dot ext `.src` is too generic for the map). if (isErlangAppFile(filePath)) return 'erlang'; + if (source && isPotentialBashShebangPath(filePath) && hasBashShebang(source)) return 'bash'; const lang = (overrides && overrides[ext]) || EXTENSION_MAP[ext] || 'unknown'; // .h files could be C, C++, or Objective-C — check source content @@ -510,6 +533,7 @@ export function getLanguageDisplayName(language: Language): string { javascript: 'JavaScript', tsx: 'TypeScript (TSX)', jsx: 'JavaScript (JSX)', + bash: 'Bash / shell', python: 'Python', go: 'Go', rust: 'Rust', diff --git a/src/extraction/index.ts b/src/extraction/index.ts index bda1746ba..ab4e03386 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -20,7 +20,7 @@ import { import { QueryBuilder } from '../db/queries'; import { extractFromSource } from './tree-sitter'; import { ParseWorkerPool, resolveParsePoolSize } from './parse-pool'; -import { detectLanguage, isSourceFile, isLanguageSupported, isFileLevelOnlyLanguage, initGrammars, loadGrammarsForLanguages } from './grammars'; +import { detectLanguage, isSourceFile, isLanguageSupported, isFileLevelOnlyLanguage, initGrammars, loadGrammarsForLanguages, hasBashShebang, isPotentialBashShebangPath } from './grammars'; import { loadExtensionOverrides, loadIncludeIgnoredPatterns, loadExcludePatterns, loadIncludePatterns } from '../project-config'; import { isCodeGraphDataDir } from '../directory'; import { logDebug, logWarn } from '../errors'; @@ -117,6 +117,34 @@ export function hashContent(content: string): string { return crypto.createHash('sha256').update(content).digest('hex'); } +/** Read only the shebang-sized prefix needed to classify extensionless shell scripts. */ +function fileHasBashShebang(absPath: string): boolean { + let fd: number | null = null; + try { + fd = fs.openSync(absPath, 'r'); + const buf = Buffer.alloc(256); + const bytes = fs.readSync(fd, buf, 0, buf.length, 0); + return hasBashShebang(buf.subarray(0, bytes).toString('utf-8')); + } catch { + return false; + } finally { + if (fd !== null) { + try { fs.closeSync(fd); } catch { /* ignore */ } + } + } +} + +/** Source-file predicate with content-backed Bash shebang support. */ +function isSourceFileAtPath(absPath: string, relativePath: string, overrides?: Record): boolean { + if (isSourceFile(relativePath, overrides)) return true; + return isPotentialBashShebangPath(relativePath) && fileHasBashShebang(absPath); +} + +function detectLanguageForGrammarLoad(filePath: string, overrides?: Record): Language { + const lang = detectLanguage(filePath, undefined, overrides); + return lang === 'unknown' && isPotentialBashShebangPath(filePath) ? 'bash' : lang; +} + /** * Skip files larger than this (bytes). Generated bundles, minified JS, and * vendored blobs blow the WASM heap and the worker-recycle budget for no useful @@ -424,7 +452,7 @@ function collectIncludedFiles( if (defaults.ignores(rel)) return; if (!include.ignores(rel)) return; if (exclude && exclude.ignores(rel)) return; - if (!isSourceFile(rel, overrides)) return; + if (!isSourceFileAtPath(abs, rel, overrides)) return; out.add(rel); } }; @@ -1112,7 +1140,8 @@ function collectGitStatus(repoDir: string, prefix: string, out: GitChanges, over } const filePath = normalizePath(prefix + rel); - if (!isSourceFile(filePath, overrides)) continue; + const sourceFile = isSourceFileAtPath(path.join(repoDir, rel), filePath, overrides); + if (!sourceFile && !(statusCode.includes('D') && isPotentialBashShebangPath(filePath))) continue; if (statusCode.includes('D')) { // Deletions stay unfiltered: getChangedFiles acts on one only when the @@ -1173,7 +1202,7 @@ export function scanDirectory( const files: string[] = []; let count = 0; for (const filePath of gitFiles) { - if (isSourceFile(filePath, overrides)) { + if (isSourceFileAtPath(path.join(rootDir, filePath), filePath, overrides)) { files.push(filePath); count++; onProgress?.(count, filePath); @@ -1202,7 +1231,7 @@ export async function scanDirectoryAsync( const files: string[] = []; let count = 0; for (const filePath of gitFiles) { - if (isSourceFile(filePath, overrides)) { + if (isSourceFileAtPath(path.join(rootDir, filePath), filePath, overrides)) { files.push(filePath); count++; onProgress?.(count, filePath); @@ -1306,7 +1335,7 @@ function scanDirectoryWalk( walk(fullPath, active); } } else if (stat.isFile()) { - if (!isIgnored(fullPath, false, active) && isSourceFile(relativePath, overrides)) { + if (!isIgnored(fullPath, false, active) && isSourceFileAtPath(fullPath, relativePath, overrides)) { files.push(relativePath); count++; onProgress?.(count, relativePath); @@ -1323,7 +1352,7 @@ function scanDirectoryWalk( walk(fullPath, active); } } else if (entry.isFile()) { - if (!isIgnored(fullPath, false, active) && isSourceFile(relativePath, overrides)) { + if (!isIgnored(fullPath, false, active) && isSourceFileAtPath(fullPath, relativePath, overrides)) { files.push(relativePath); count++; onProgress?.(count, relativePath); @@ -1523,8 +1552,11 @@ export class ExtractionOrchestrator { }); await new Promise(resolve => setImmediate(resolve)); - // Detect needed languages and load grammars in the parse worker - const neededLanguages = [...new Set(files.map((f) => detectLanguage(f, undefined, overrides)))]; + // Detect needed languages and load grammars in the parse worker. Extensionless + // Bash scripts were admitted by a content-backed shebang check during scan, + // but content is not available here yet, so include the Bash grammar for + // extensionless scanned files whose path alone still reports unknown. + const neededLanguages = [...new Set(files.map((f) => detectLanguageForGrammarLoad(f, overrides)))]; // .h files default to 'c' but may be C++ — ensure cpp grammar is loaded when c is needed if (neededLanguages.includes('c') && !neededLanguages.includes('cpp')) { neededLanguages.push('cpp'); @@ -2290,7 +2322,7 @@ export class ExtractionOrchestrator { // Load only grammars needed for changed files if (filesToIndex.length > 0) { const overrides = loadExtensionOverrides(this.rootDir); - const neededLanguages = [...new Set(filesToIndex.map((f) => detectLanguage(f, undefined, overrides)))]; + const neededLanguages = [...new Set(filesToIndex.map((f) => detectLanguageForGrammarLoad(f, overrides)))]; // .h files default to 'c' but may be C++ — ensure cpp grammar is loaded if (neededLanguages.includes('c') && !neededLanguages.includes('cpp')) { neededLanguages.push('cpp'); diff --git a/src/extraction/languages/bash.ts b/src/extraction/languages/bash.ts new file mode 100644 index 000000000..33126ddd8 --- /dev/null +++ b/src/extraction/languages/bash.ts @@ -0,0 +1,243 @@ +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import { getChildByField, getNodeText } from '../tree-sitter-helpers'; +import type { ExtractorContext, LanguageExtractor } from '../tree-sitter-types'; + +const SHELL_SOURCE_COMMANDS = new Set(['source', '.']); +const SHELL_INTERPRETERS = new Set(['bash', 'sh']); +const SHELL_WRAPPER_COMMANDS = new Set(['exec', 'command']); +const SKIP_CALL_COMMANDS = new Set([':', '[', '[[', ']', ']]']); + +function commandName(node: SyntaxNode, source: string): string | null { + const nameNode = getChildByField(node, 'name') || node.namedChildren.find((c) => c.type === 'command_name'); + if (!nameNode) return null; + const name = getNodeText(nameNode, source).trim(); + if (!name || /[\s$`{}()[\];"'<>]/.test(name)) return null; + return name; +} + +function commandArguments(node: SyntaxNode): SyntaxNode[] { + const args: SyntaxNode[] = []; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child) continue; + if (child.type === 'command_name' || child.type === 'variable_assignment') continue; + args.push(child); + } + return args; +} + +function staticShellWord(node: SyntaxNode, source: string): string | null { + // A plain `word` is static only when it has no expansion children. Quoted + // strings are accepted when their content is literal; command/parameter + // substitutions make the path dynamic and are deliberately skipped. + if (node.type === 'word') { + const text = getNodeText(node, source).trim(); + return /[$`{}()[\];"'<>]/.test(text) ? null : text; + } + + if (node.type === 'raw_string' || node.type === 'string') { + if (node.namedChildren.some((c) => c.type !== 'string_content')) return null; + const text = getNodeText(node, source).trim(); + const unquoted = text.replace(/^['"]|['"]$/g, ''); + return /[$`{}()[\];"'<>]/.test(unquoted) ? null : unquoted; + } + + return null; +} + +function isStaticProjectPath(value: string): boolean { + return ( + (value.startsWith('./') || value.startsWith('../')) && + !/[\s{}()[\];"'<>$`]/.test(value) + ); +} + +function isScriptPathCommand(name: string): boolean { + return isStaticProjectPath(name) || /^\.\.?\//.test(name); +} + +function emitImport(ctx: ExtractorContext, anchor: SyntaxNode, importPath: string): void { + if (!isStaticProjectPath(importPath)) return; + + const signature = getNodeText(anchor, ctx.source).trim(); + ctx.createNode('import', importPath, anchor, { signature }); + + const fromNodeId = ctx.nodeStack[ctx.nodeStack.length - 1]; + if (!fromNodeId) return; + ctx.addUnresolvedReference({ + fromNodeId, + referenceName: importPath, + referenceKind: 'imports', + filePath: ctx.filePath, + line: anchor.startPosition.row + 1, + column: anchor.startPosition.column, + }); +} + +function emitCall(ctx: ExtractorContext, anchor: SyntaxNode, name: string): void { + if (!name || SKIP_CALL_COMMANDS.has(name)) return; + const fromNodeId = ctx.nodeStack[ctx.nodeStack.length - 1]; + if (!fromNodeId) return; + ctx.addUnresolvedReference({ + fromNodeId, + referenceName: name, + referenceKind: 'calls', + filePath: ctx.filePath, + line: anchor.startPosition.row + 1, + column: anchor.startPosition.column, + }); +} + +function handleCommand(node: SyntaxNode, ctx: ExtractorContext): void { + const name = commandName(node, ctx.source); + if (!name) { + visitCommandChildren(node, ctx); + return; + } + + const args = commandArguments(node); + const firstArg = args[0] ? staticShellWord(args[0], ctx.source) : null; + + if (SHELL_SOURCE_COMMANDS.has(name)) { + if (firstArg) emitImport(ctx, node, firstArg); + visitCommandChildren(node, ctx); + return; + } + + // `bash ./script.sh`, `sh ../lib/tool`, and wrapper forms such as + // `exec ./script.sh` are file dependencies as well as command invocations. + if (firstArg && (SHELL_INTERPRETERS.has(name) || SHELL_WRAPPER_COMMANDS.has(name))) { + emitImport(ctx, node, firstArg); + } + if (isScriptPathCommand(name)) { + emitImport(ctx, node, name); + } + + emitCall(ctx, node, name); + visitCommandChildren(node, ctx); +} + +function visitCommandChildren(node: SyntaxNode, ctx: ExtractorContext): void { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child || child.type === 'command_name') continue; + ctx.visitNode(child); + } +} + +function assignmentName(node: SyntaxNode, source: string): string | null { + const nameNode = getChildByField(node, 'name') || node.namedChildren.find((c) => c.type === 'variable_name'); + if (!nameNode) return null; + const name = getNodeText(nameNode, source).trim(); + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ? name : null; +} + +function assignmentValue(node: SyntaxNode): SyntaxNode | null { + return getChildByField(node, 'value') || node.namedChildren.find((c) => c.type !== 'variable_name') || null; +} + +function declarationPrefix(node: SyntaxNode, firstAssignment: SyntaxNode | null, source: string): string { + const end = firstAssignment ? firstAssignment.startIndex : node.endIndex; + return source.slice(node.startIndex, end); +} + +function declarationFlags(prefix: string): { kind: 'variable' | 'constant'; isExported: boolean } { + const isReadonly = /\breadonly\b/.test(prefix) || /\b(?:declare|typeset|local)\b[\s\S]*?(?:^|\s)-[A-Za-z]*r[A-Za-z]*\b/.test(prefix); + const isExported = /\bexport\b/.test(prefix) || /\b(?:declare|typeset)\b[\s\S]*?(?:^|\s)-[A-Za-z]*x[A-Za-z]*\b/.test(prefix); + return { kind: isReadonly ? 'constant' : 'variable', isExported }; +} + +function createAssignmentNode( + assignment: SyntaxNode, + ctx: ExtractorContext, + kind: 'variable' | 'constant', + isExported = false +): void { + const name = assignmentName(assignment, ctx.source); + if (!name) return; + + const value = assignmentValue(assignment); + const initValue = value ? getNodeText(value, ctx.source).slice(0, 100) : undefined; + const signature = initValue ? `= ${initValue}${initValue.length >= 100 ? '...' : ''}` : undefined; + + ctx.createNode(kind, name, assignment, { signature, isExported }); + + if (value) ctx.visitNode(value); +} + +function handleDeclarationCommand(node: SyntaxNode, ctx: ExtractorContext): void { + const assignments = node.namedChildren.filter((c) => c.type === 'variable_assignment'); + const prefix = declarationPrefix(node, assignments[0] ?? null, ctx.source); + const flags = declarationFlags(prefix); + + for (const assignment of assignments) { + createAssignmentNode(assignment, ctx, flags.kind, flags.isExported); + } + + // Preserve command substitutions in declaration arguments that are not part of + // an assignment (rare, but harmless to walk). + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child || child.type === 'variable_assignment') continue; + ctx.visitNode(child); + } +} + +function handleFunctionDefinition(node: SyntaxNode, ctx: ExtractorContext): boolean { + const nameNode = getChildByField(node, 'name') || node.namedChildren.find((c) => c.type === 'word'); + if (!nameNode) return false; + + const name = getNodeText(nameNode, ctx.source).trim(); + if (!name) return false; + + const fn = ctx.createNode('function', name, node, { signature: '()' }); + if (!fn) return true; + + ctx.pushScope(fn.id); + const body = getChildByField(node, 'body') || node.namedChildren.find((c) => c.type === 'compound_statement'); + if (body) ctx.visitNode(body); + ctx.popScope(); + return true; +} + +export const bashExtractor: LanguageExtractor = { + functionTypes: ['function_definition'], + classTypes: [], + methodTypes: [], + interfaceTypes: [], + structTypes: [], + enumTypes: [], + typeAliasTypes: [], + importTypes: [], + callTypes: [], + variableTypes: [], + nameField: 'name', + bodyField: 'body', + paramsField: '', + getSignature: () => '()', + + visitNode: (node, ctx) => { + if (node.type === 'function_definition') { + return handleFunctionDefinition(node, ctx); + } + + if (node.type === 'declaration_command') { + handleDeclarationCommand(node, ctx); + return true; + } + + if (node.type === 'variable_assignment') { + if (node.parent?.type !== 'declaration_command') { + createAssignmentNode(node, ctx, 'variable'); + } + return true; + } + + if (node.type === 'command') { + handleCommand(node, ctx); + return true; + } + + return false; + }, +}; diff --git a/src/extraction/languages/index.ts b/src/extraction/languages/index.ts index 6b760b01d..57bd933e6 100644 --- a/src/extraction/languages/index.ts +++ b/src/extraction/languages/index.ts @@ -10,6 +10,7 @@ import type { LanguageExtractor } from '../tree-sitter-types'; import { typescriptExtractor } from './typescript'; import { javascriptExtractor } from './javascript'; +import { bashExtractor } from './bash'; import { pythonExtractor } from './python'; import { goExtractor } from './go'; import { rustExtractor } from './rust'; @@ -42,6 +43,7 @@ export const EXTRACTORS: Partial> = { tsx: typescriptExtractor, javascript: javascriptExtractor, jsx: javascriptExtractor, + bash: bashExtractor, python: pythonExtractor, go: goExtractor, rust: rustExtractor, diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index bbb1303b3..21665b1b3 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -22,6 +22,7 @@ const EXTENSION_RESOLUTION: Record = { // rewritten to the member's directory; lowercase variants for safety. arkts: ['.ets', '.ts', '.d.ts', '.js', '/Index.ets', '/index.ets', '/index.ts', '/index.js'], javascript: ['.js', '.jsx', '.mjs', '.cjs', '/index.js', '/index.jsx'], + bash: ['.sh', '.bash', '.bats', '/index.sh'], tsx: ['.tsx', '.ts', '.d.ts', '.js', '.jsx', '/index.tsx', '/index.ts', '/index.js'], jsx: ['.jsx', '.js', '/index.jsx', '/index.js'], // SFC consumers import plain TS/JS, sibling components, and barrels diff --git a/src/types.ts b/src/types.ts index d160d044e..85d7d944d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -69,6 +69,7 @@ export const LANGUAGES = [ 'tsx', 'jsx', 'arkts', + 'bash', 'python', 'go', 'rust',