Skip to content

Symbol Resolution

Preprocessing automatically collects symbol references and injects missing imports before writing files to disk.

Generated code references sp(), dce(), mc(), INTERNALS without importing them. Preprocessing ensures all symbols are imported before files are written.

Additionally, barrel files (files that only re-export symbols) should not have imports added for the re-exported symbols, since they’re being re-exported, not used as references.

Single universal processor works for all files:

class SourcePreprocessor {
process(sourceFile: SourceFile): SourceFile {
const usedSymbols = collector.collect(sourceFile) // Find all identifiers
const requirements = builder.build(usedSymbols) // Lookup modules
return injector.injectImports(sourceFile, requirements) // Inject AST
}
}

Three-bucket registry (well-known + project symbols + file symbols) means same processor handles all symbol types.

ComponentPurpose
SymbolRegistrySymbol → module mapping; well-known + project + file symbol buckets
SymbolCollectorAST walk to find identifiers; filters declarations, imports, exports, and built-ins
ImportBuilderConvert symbols to requirements; group, deduplicate
ImportInjectorAdd/merge imports into AST using ts.factory
SourcePreprocessorOrchestrate: collect → build → inject

The collector performs a three-phase symbol collection process:

  1. Pre-extraction phase – Before traversing the AST, it pre-extracts:

    • Symbols from import specifiers (e.g., import { Symbol })
    • Symbols from export specifiers (e.g., export { Symbol })
    • These symbols are skipped during collection to prevent them from being treated as missing references
  2. Import extraction phase – Collects symbols that are already imported via import declarations

  3. Main traversal phase – Walks the AST collecting identifier references, filtering out:

    • Already-imported symbols
    • Export/import specifier symbols
    • Built-in globals (Promise, Array, console, etc.)
    • Local declarations (variables, functions, classes, parameters, properties)

Runs in FileWriter.writeAllFiles() before printing:

for (const sourceFile of files.values()) {
const preprocessed = sourcePreprocessor.process(sourceFile)
const printed = sourcePrinter.printFile(preprocessed)
await writeFile(outputPath, printed)
}

The registry maintains three separate buckets for different symbol types:

Stable APIs registered by Pencel and plugins—always imported regardless of file location. Examples: sp, dce, INTERNALS from @pencel/runtime; provideAppInitializer from @angular/core.

Registered on startup; never change during compilation. Enables plugins to ensure their framework symbols (Angular, React, Vue) auto-import everywhere.

Discovered from generated .gen.ts files (component classes, etc.):

TestFeatures, PenCardElement, PenInputElement // Component classes

Registered via registerInputSymbol() as symbols are discovered. Configurable per plugin via ImportPreference:

preference.style === "package"
? { symbol: "MyComp", module: "@org/components" }
: { symbol: "MyComp", module: "./my-comp.gen" }

Cached mapping of which symbols were exported from each file:

this.#fileSymbols: Map<filePath, Set<symbolNames>>

Used for incremental updates and rescanning when files change. Enables upsertFileSymbols() to track and update project symbols efficiently.

Imports from same module are consolidated:

// Before preprocessing
import { State } from "@pencel/runtime";
export class Comp {
render() {
sp(el, props); // Uses sp, dce
dce("div");
}
}
// After preprocessing
import { State, sp, dce } from "@pencel/runtime";
export class Comp {
render() {
sp(el, props);
dce("div");
}
}

Existing imports are preserved; only missing symbols are added.

See Plugin System for details on how plugins register and control symbols.

Plugins register symbols during initialization:

this.#registry.registerWellKnown([
{ symbol: "myHelper", module: "@my-org/helpers" }
]);

Plugins can control how symbols are imported in specific files using ImportPreference. See Plugin System - File Creation with Preferences for complete examples.

Quick reference:

const preference: ImportPreference = {
style: "package", // "package" | "relative" | "deep"
packageName: "@my/lib", // for "package" style
symbolOverrides: [
{ match: "Component1", packageName: "@other/lib" },
{ match: "*Helper", style: "relative" },
],
};
this.#sourceFiles.newFile(fileName, statements, { preference });

During preprocessing, preferences control the import resolution:

  1. Well-Known symbols – Always use their registered module
  2. Symbol overrides – Apply pattern-matched rules if present
  3. Global style – Fall back to file’s default preference
  • Collection – Linear AST walk per file
  • Registry lookup – O(1) hash table, pattern matching on symbol overrides
  • Injection – Single AST pass
  • Total – Typically less than 1ms per file
graph TD
    Symbol["Symbol"]
    Symbol --> WK["Well-known?"]
    WK -->|Yes| Import1["Use registered module"]
    WK -->|No| Pref["File has preferences?"]
    Pref -->|Yes| Override["Match symbol overrides?"]
    Override -->|Yes| Import2["Apply override rules"]
    Override -->|No| GlobalStyle["Apply global style"]
    Pref -->|No| Input["Input (user-defined)?"]
    Input -->|Yes| Import3["Use registered module"]
    Input -->|No| Ignore["Ignore"]
    
    style Symbol fill:#fff59d,stroke:#fbc02d,color:#000
    style WK fill:#42a5f5,stroke:#1565c0,color:#fff
    style Pref fill:#ab47bc,stroke:#6a1b9a,color:#fff
    style Override fill:#ab47bc,stroke:#6a1b9a,color:#fff
    style GlobalStyle fill:#ab47bc,stroke:#6a1b9a,color:#fff
    style Import1 fill:#66bb6a,stroke:#2e7d32,color:#fff
    style Import2 fill:#66bb6a,stroke:#2e7d32,color:#fff
    style Import3 fill:#66bb6a,stroke:#2e7d32,color:#fff
    style Ignore fill:#e0e0e0,stroke:#616161,color:#000