Skip to content
Canopy is in pre-release. These docs describe the product at its public launch — commands, tool names, and integration examples reflect what you'll see once binaries ship. Join the waitlist →

AST Parsing

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.

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.

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 path reachable?” 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.

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.

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.

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 → edge
import { useState } from 'react'; // external package → no edge
use crate::utils::foo; // crate-internal → edge
use std::collections::HashMap; // std → no edge

SCIP: 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, …):

Terminal window
canopy ingest scip ./index.scip

After 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.

  • Alias resolution: tsconfig.json path aliases (@/components/* → src/components/*) resolve when the config is found at index time. Runtime module-resolution tricks cannot be resolved statically.
  • Dynamic imports: require() and import() 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 ./bar and is marked as a re-export.