AST Parsing
Why AST instead of regex?
Section titled “Why AST instead of regex?”Canopy’s structural answers — dependents, public surface, wiring, cycles — are computed from AST (Abstract Syntax Tree) parsing, not text matching. This is a deliberate choice with real consequences.
The regex failure mode
Section titled “The regex failure mode”Consider this code:
// This is a comment: import { foo } from './utils'const str = "import { bar } from './helpers'";import { baz } from './services';A regex like ^import .* from '(.*)' would match all three lines. Two of them are not
real imports — one is in a comment, one is in a string literal.
Multiply this by 50,000 files and you get a dependency graph full of phantom edges that don’t represent actual code relationships.
An AST parser reads the file the way the compiler does. It knows the first line is a comment node, the second a string literal, and only the third an import declaration. Only the real import becomes an edge.
What AST parsing enables
Section titled “What AST parsing enables”Precise symbols and edges are what make one-call answers trustworthy:
- Accurate blast radius — “what depends on
path?” returns actual importers, not grep matches. - Real dead-code detection — “is
pathreachable?” is a graph reachability check; a file with zero inbound edges is structurally unused, not just lexically unmatched. - Cycle detection — “are there import cycles?” only means something if the graph is accurate.
- Exact public surfaces — “public surface of
path” is read from export declarations, not inferred from naming.
The tree-sitter advantage
Section titled “The tree-sitter advantage”Canopy uses tree-sitter grammars. tree-sitter is:
- Error-tolerant — files with syntax errors parse into a partial tree instead of failing, so indexing survives real-world repos.
- Fast — parsing a 1,000-line file takes microseconds, which is what makes
incremental re-indexing (and
canopy serve --watch) cheap. - Multi-language — one extraction pipeline, many grammars. Canopy’s symbol extractor is language-agnostic; the grammar carries the language specifics.
Supported languages
Section titled “Supported languages”Canopy indexes 55+ languages and formats for symbols and imports, including: Rust, TypeScript/TSX, JavaScript, Python, Go, Java, Kotlin, C, C++, C#, Swift, Ruby, PHP, Lua, Perl, Objective-C, GDScript, shell/PowerShell/batch, HTML/CSS, GraphQL, Protobuf, Prisma, Dockerfile, CMake/Make/Gradle/MSBuild, YAML/TOML/INI/JSON, Jupyter notebooks, and Markdown.
“Indexed” means exactly this: symbol extraction plus import edges — declarations into the symbol table, resolved imports into the dependency graph. All indexed content is also searchable through the full-text and semantic layers. Canopy does not claim deeper per-language semantics than that.
What an import edge is
Section titled “What an import edge is”An edge is created when an import statement resolves to a file inside the indexed repo. Standard-library and third-party imports resolve outside the repo and do not create edges — the graph describes your code’s structure.
import { foo } from './utils'; // resolves in-repo → edgeimport { useState } from 'react'; // external package → no edgeuse crate::utils::foo; // crate-internal → edgeuse std::collections::HashMap; // std → no edgeSCIP: upgrading to compiler-resolved edges
Section titled “SCIP: upgrading to compiler-resolved edges”tree-sitter parses syntax but doesn’t run the compiler, so import resolution is
heuristic — Canopy maps import paths to files using naming conventions and path maps
(tsconfig.json, pyproject.toml) where available.
For compiler-precise edges, ingest a SCIP index produced by your language toolchain (rust-analyzer, scip-typescript, scip-python, …):
canopy ingest scip ./index.scipAfter ingestion, the affected edges are upgraded from heuristic to compiler-resolved — most valuable for TypeScript projects with complex path aliases or Python projects with namespace packages. SCIP ingestion is a Pro-tier capability. See How-To: Multi-language Repo.
Limits and caveats
Section titled “Limits and caveats”- Alias resolution:
tsconfig.jsonpath aliases (@/components/*→src/components/*) resolve when the config is found at index time. Runtime module-resolution tricks cannot be resolved statically. - Dynamic imports:
require()andimport()calls whose path is computed at runtime (require('./plugins/' + name)) are not tracked as edges. - Monorepos: cross-package edges are tracked when all packages sit under the indexed root. See How-To: Configure Monorepo.
- Re-exports:
export { foo } from './bar'creates an edge to./barand is marked as a re-export.