| number | 18 |
|---|---|
| title | Source Map Architecture for Zylisp |
| author | Duncan McGreggor |
| created | 2025-10-04 |
| updated | 2025-10-04 |
| state | Draft |
| supersedes | None |
| superseded-by | None |
Project: zylisp/core
Purpose: Track source code positions through all compilation phases
Status: Design specification
This document specifies a source map system that tracks the provenance of code through Zylisp's multi-stage compilation pipeline. This enables reporting errors at any compilation stage with accurate references back to the original source code location.
Zylisp's compilation pipeline has multiple transformation stages:
Original .zl source
↓
Desugared syntax
↓
Macro-expanded Zylisp
↓
Zylisp IR (S-expression AST)
↓
Go AST
↓
Generated Go code
When an error occurs at any stage, we need to report it with the location in the original .zl source, not the intermediate representation where the error was detected.
Traditional approaches (like Go's token.Pos) break down because:
- Positions are absolute byte offsets tied to a specific file
- Transformations invalidate these positions
- Round-tripping through serialization (S-expressions) loses position context
Instead of trying to preserve positions through transformations, we:
- Assign unique IDs to every AST node at parse time
- Track parent relationships as each transformation creates new nodes
- Store original positions only at the input layer
- Walk the chain backward when reporting errors
This is how TypeScript, ClojureScript, Elm, and other compile-to-X languages work.
Location in zylisp/core/sourcemap/types.go:
package sourcemap
// SourceLocation represents a position in original source code
type SourceLocation struct {
File string // File path
Line int // 1-based line number
Column int // 1-based column number
Length int // Length of the source span in characters
}
// NodeID is a unique identifier for an AST node
// IDs are globally unique within a compilation session
type NodeID uint64
// SourceMap tracks the provenance of nodes through transformations
type SourceMap struct {
// Name of this transformation layer (for debugging)
layer string
// Maps nodes in this layer to nodes in the previous layer
parentNode map[NodeID]NodeID
// Original source positions (only populated at input layer)
originalPos map[NodeID]SourceLocation
// Link to previous transformation's source map
previous *SourceMap
}
// NewSourceMap creates a new source map for a transformation layer
func NewSourceMap(layer string, previous *SourceMap) *SourceMap {
return &SourceMap{
layer: layer,
parentNode: make(map[NodeID]NodeID),
originalPos: make(map[NodeID]SourceLocation),
previous: previous,
}
}
// NewInputSourceMap creates a source map for the input layer (parser)
func NewInputSourceMap(layer string) *SourceMap {
return &SourceMap{
layer: layer,
parentNode: make(map[NodeID]NodeID),
originalPos: make(map[NodeID]SourceLocation),
previous: nil,
}
}
// RecordOriginal records the original source location for a node (input layer only)
func (sm *SourceMap) RecordOriginal(nodeID NodeID, loc SourceLocation) {
sm.originalPos[nodeID] = loc
}
// RecordTransform records that newNodeID was created from oldNodeID
func (sm *SourceMap) RecordTransform(newNodeID, oldNodeID NodeID) {
sm.parentNode[newNodeID] = oldNodeID
}
// OriginalLocation walks the chain to find the original source location
func (sm *SourceMap) OriginalLocation(nodeID NodeID) *SourceLocation {
current := sm
currentID := nodeID
// Walk backward through the transformation chain
for current != nil {
// Check if this layer has the original position
if loc, ok := current.originalPos[currentID]; ok {
return &loc
}
// Otherwise, follow the parent link
if parentID, ok := current.parentNode[currentID]; ok {
currentID = parentID
current = current.previous
} else {
// Dead end - no parent and no original position
break
}
}
return nil
}
// GetLayer returns the layer name (for debugging)
func (sm *SourceMap) GetLayer() string {
return sm.layer
}
// DebugTrace returns the full provenance chain for debugging
func (sm *SourceMap) DebugTrace(nodeID NodeID) []string {
var trace []string
current := sm
currentID := nodeID
for current != nil {
trace = append(trace, fmt.Sprintf("%s: node %d", current.layer, currentID))
if loc, ok := current.originalPos[currentID]; ok {
trace = append(trace, fmt.Sprintf(" → %s:%d:%d", loc.File, loc.Line, loc.Column))
break
}
if parentID, ok := current.parentNode[currentID]; ok {
currentID = parentID
current = current.previous
} else {
trace = append(trace, " → (no parent)")
break
}
}
return trace
}Location in zylisp/core/sourcemap/idgen.go:
package sourcemap
import "sync/atomic"
// IDGenerator generates unique node IDs
type IDGenerator struct {
counter uint64
}
// NewIDGenerator creates a new ID generator
func NewIDGenerator() *IDGenerator {
return &IDGenerator{counter: 0}
}
// Next returns the next unique ID
func (g *IDGenerator) Next() NodeID {
return NodeID(atomic.AddUint64(&g.counter, 1))
}
// GlobalIDGen is a global ID generator for convenience
// Each compilation session should create its own, but this is available for simple cases
var GlobalIDGen = NewIDGenerator()The parser is the input layer that records original source positions.
Location in zylisp/lang/parser/parser.go:
package parser
import "zylisp/core/sourcemap"
type Parser struct {
source string
file string
pos int
line int
column int
idGen *sourcemap.IDGenerator
sourceMap *sourcemap.SourceMap
}
func NewParser(source, filename string) *Parser {
return &Parser{
source: source,
file: filename,
pos: 0,
line: 1,
column: 1,
idGen: sourcemap.NewIDGenerator(),
sourceMap: sourcemap.NewInputSourceMap("parser"),
}
}
func (p *Parser) ParseExpr() (*Expr, error) {
startLine := p.line
startCol := p.column
startPos := p.pos
// Generate unique ID
id := p.idGen.Next()
// Parse the expression
expr := p.parseExprImpl()
expr.ID = id
// Record original position
length := p.pos - startPos
p.sourceMap.RecordOriginal(id, sourcemap.SourceLocation{
File: p.file,
Line: startLine,
Column: startCol,
Length: length,
})
return expr, nil
}
// GetSourceMap returns the parser's source map
func (p *Parser) GetSourceMap() *sourcemap.SourceMap {
return p.sourceMap
}Every AST node needs an ID field:
// In zylisp/lang/ast/expr.go
type Expr struct {
ID sourcemap.NodeID // Unique identifier
Type ExprType
// ... other fields
}Location in zylisp/lang/desugar/desugar.go:
package desugar
import "zylisp/core/sourcemap"
type Desugarer struct {
idGen *sourcemap.IDGenerator
sourceMap *sourcemap.SourceMap
}
func NewDesugarer(previousMap *sourcemap.SourceMap) *Desugarer {
return &Desugarer{
idGen: sourcemap.NewIDGenerator(),
sourceMap: sourcemap.NewSourceMap("desugar", previousMap),
}
}
func (d *Desugarer) Desugar(expr *ast.Expr) (*ast.Expr, error) {
newID := d.idGen.Next()
// Desugar the expression
result := d.desugarImpl(expr)
result.ID = newID
// Record that new node came from original
d.sourceMap.RecordTransform(newID, expr.ID)
return result, nil
}
func (d *Desugarer) GetSourceMap() *sourcemap.SourceMap {
return d.sourceMap
}Location in zylisp/lang/macro/expander.go:
package macro
import "zylisp/core/sourcemap"
type Expander struct {
idGen *sourcemap.IDGenerator
sourceMap *sourcemap.SourceMap
}
func NewExpander(previousMap *sourcemap.SourceMap) *Expander {
return &Expander{
idGen: sourcemap.NewIDGenerator(),
sourceMap: sourcemap.NewSourceMap("macro-expand", previousMap),
}
}
func (e *Expander) Expand(expr *ast.Expr) ([]*ast.Expr, error) {
// Macro might expand to multiple expressions
var results []*ast.Expr
for _, expanded := range e.expandImpl(expr) {
newID := e.idGen.Next()
expanded.ID = newID
// All generated nodes trace back to the macro call site
e.sourceMap.RecordTransform(newID, expr.ID)
results = append(results, expanded)
}
return results, nil
}
func (e *Expander) GetSourceMap() *sourcemap.SourceMap {
return e.sourceMap
}Location in zylisp/lang/ir/generator.go:
package ir
import "zylisp/core/sourcemap"
type Generator struct {
idGen *sourcemap.IDGenerator
sourceMap *sourcemap.SourceMap
}
func NewGenerator(previousMap *sourcemap.SourceMap) *Generator {
return &Generator{
idGen: sourcemap.NewIDGenerator(),
sourceMap: sourcemap.NewSourceMap("ir-gen", previousMap),
}
}
func (g *Generator) Generate(expr *ast.Expr) (*IR, error) {
newID := g.idGen.Next()
ir := g.generateImpl(expr)
ir.ID = newID
g.sourceMap.RecordTransform(newID, expr.ID)
return ir, nil
}
func (g *Generator) GetSourceMap() *sourcemap.SourceMap {
return g.sourceMap
}This is where we stop caring about Go positions and just track provenance.
Location in zylisp/zast/codegen/generator.go:
package codegen
import (
"go/ast"
"go/token"
"zylisp/core/sourcemap"
)
type Generator struct {
idGen *sourcemap.IDGenerator
sourceMap *sourcemap.SourceMap
// Map Go AST nodes to their IDs (since ast.Node doesn't have ID field)
goNodeIDs map[ast.Node]sourcemap.NodeID
}
func NewGenerator(previousMap *sourcemap.SourceMap) *Generator {
return &Generator{
idGen: sourcemap.NewIDGenerator(),
sourceMap: sourcemap.NewSourceMap("go-ast", previousMap),
goNodeIDs: make(map[ast.Node]sourcemap.NodeID),
}
}
func (g *Generator) Generate(ir *IR) (*ast.File, error) {
// Generate Go AST with DUMMY positions
// We don't care about token.Pos - we track provenance separately
file := &ast.File{
Package: token.NoPos, // Use NoPos or 0
Name: &ast.Ident{
NamePos: token.NoPos,
Name: "main",
},
// ...
}
// Track provenance for the file node
fileID := g.idGen.Next()
g.goNodeIDs[file] = fileID
g.sourceMap.RecordTransform(fileID, ir.ID)
return file, nil
}
func (g *Generator) generateExpr(irExpr *IR) ast.Expr {
// Create Go expression with dummy positions
goExpr := &ast.BinaryExpr{
X: /* ... */,
OpPos: token.NoPos,
Op: token.ADD,
Y: /* ... */,
}
// Track provenance
exprID := g.idGen.Next()
g.goNodeIDs[goExpr] = exprID
g.sourceMap.RecordTransform(exprID, irExpr.ID)
return goExpr
}
func (g *Generator) GetSourceMap() *sourcemap.SourceMap {
return g.sourceMap
}
// GetNodeID returns the ID for a Go AST node
func (g *Generator) GetNodeID(node ast.Node) (sourcemap.NodeID, bool) {
id, ok := g.goNodeIDs[node]
return id, ok
}Location in zylisp/core/errors/compiler_error.go:
package errors
import (
"fmt"
"zylisp/core/sourcemap"
)
type CompilerError struct {
Message string
NodeID sourcemap.NodeID
SourceMap *sourcemap.SourceMap
// Cached location (computed lazily)
location *sourcemap.SourceLocation
}
func NewCompilerError(msg string, nodeID sourcemap.NodeID, sm *sourcemap.SourceMap) *CompilerError {
return &CompilerError{
Message: msg,
NodeID: nodeID,
SourceMap: sm,
}
}
func (e *CompilerError) Location() *sourcemap.SourceLocation {
if e.location == nil {
e.location = e.SourceMap.OriginalLocation(e.NodeID)
}
return e.location
}
func (e *CompilerError) Error() string {
loc := e.Location()
if loc != nil {
return fmt.Sprintf("%s:%d:%d: %s",
loc.File, loc.Line, loc.Column, e.Message)
}
return fmt.Sprintf("(unknown location): %s", e.Message)
}
// WithContext adds source code context to the error message
func (e *CompilerError) WithContext(source string) string {
loc := e.Location()
if loc == nil {
return e.Error()
}
// Extract the relevant line from source
lines := strings.Split(source, "\n")
if loc.Line < 1 || loc.Line > len(lines) {
return e.Error()
}
line := lines[loc.Line-1]
// Build error with context
var b strings.Builder
fmt.Fprintf(&b, "%s:%d:%d: %s\n", loc.File, loc.Line, loc.Column, e.Message)
fmt.Fprintf(&b, "%s\n", line)
// Add caret pointing to error location
for i := 1; i < loc.Column; i++ {
b.WriteByte(' ')
}
b.WriteByte('^')
if loc.Length > 1 {
for i := 1; i < loc.Length; i++ {
b.WriteByte('~')
}
}
return b.String()
}
// DebugTrace returns the full provenance chain
func (e *CompilerError) DebugTrace() string {
trace := e.SourceMap.DebugTrace(e.NodeID)
var b strings.Builder
b.WriteString("Error provenance:\n")
for _, step := range trace {
b.WriteString(" ")
b.WriteString(step)
b.WriteByte('\n')
}
return b.String()
}When the Go compiler reports an error in generated code:
Location in zylisp/cli/compile/go_errors.go:
package compile
import (
"go/ast"
"zylisp/core/errors"
"zylisp/core/sourcemap"
)
type GoErrorTranslator struct {
generator *codegen.Generator // Has the Go AST and source map
}
func (t *GoErrorTranslator) TranslateError(goErr error, node ast.Node) *errors.CompilerError {
// Find the node ID for the Go AST node that caused the error
nodeID, ok := t.generator.GetNodeID(node)
if !ok {
// Fallback: error with no source tracking
return errors.NewCompilerError(goErr.Error(), 0, nil)
}
// Create error with full source map chain
return errors.NewCompilerError(
goErr.Error(),
nodeID,
t.generator.GetSourceMap(),
)
}Location in zylisp/cli/compile/pipeline.go:
package compile
import "zylisp/core/sourcemap"
type CompilationPipeline struct {
source string
filename string
parser *parser.Parser
desugarer *desugar.Desugarer
expander *macro.Expander
irGen *ir.Generator
codeGen *codegen.Generator
}
func NewPipeline(source, filename string) *CompilationPipeline {
return &CompilationPipeline{
source: source,
filename: filename,
}
}
func (p *CompilationPipeline) Compile() (*ast.File, error) {
// Layer 1: Parse
p.parser = parser.NewParser(p.source, p.filename)
expr, err := p.parser.ParseExpr()
if err != nil {
return nil, err
}
// Layer 2: Desugar
p.desugarer = desugar.NewDesugarer(p.parser.GetSourceMap())
desugared, err := p.desugarer.Desugar(expr)
if err != nil {
return nil, err
}
// Layer 3: Expand macros
p.expander = macro.NewExpander(p.desugarer.GetSourceMap())
expanded, err := p.expander.Expand(desugared)
if err != nil {
return nil, err
}
// Layer 4: Generate IR
p.irGen = ir.NewGenerator(p.expander.GetSourceMap())
irCode, err := p.irGen.Generate(expanded[0])
if err != nil {
return nil, err
}
// Layer 5: Generate Go AST
p.codeGen = codegen.NewGenerator(p.irGen.GetSourceMap())
goFile, err := p.codeGen.Generate(irCode)
if err != nil {
return nil, err
}
return goFile, nil
}
// GetFinalSourceMap returns the complete source map chain
func (p *CompilationPipeline) GetFinalSourceMap() *sourcemap.SourceMap {
return p.codeGen.GetSourceMap()
}Location in zylisp/repl/repl.go:
package repl
func (r *REPL) Eval(input string) {
pipeline := compile.NewPipeline(input, "<repl>")
goFile, err := pipeline.Compile()
if err != nil {
// Error during compilation
if compErr, ok := err.(*errors.CompilerError); ok {
fmt.Println(compErr.WithContext(input))
if r.debug {
fmt.Println(compErr.DebugTrace())
}
} else {
fmt.Println("Error:", err)
}
return
}
// Try to run the generated code
err = r.runGoCode(goFile)
if err != nil {
// Error from Go compiler on generated code
translator := &compile.GoErrorTranslator{/* ... */}
compErr := translator.TranslateError(err, /* node */)
fmt.Println(compErr.WithContext(input))
}
}zylisp/
├── core/ # NEW: Core shared infrastructure
│ ├── sourcemap/
│ │ ├── types.go # SourceMap, SourceLocation, NodeID
│ │ ├── idgen.go # ID generator
│ │ └── sourcemap_test.go
│ ├── errors/
│ │ ├── compiler_error.go # CompilerError with source tracking
│ │ └── errors_test.go
│ └── go.mod
│
├── lang/ # Language implementation
│ ├── ast/
│ │ └── expr.go # Add NodeID field to all AST nodes
│ ├── parser/
│ │ └── parser.go # Input layer: records original positions
│ ├── desugar/
│ │ └── desugar.go # Tracks transformations
│ ├── macro/
│ │ └── expander.go # Tracks macro expansions
│ ├── ir/
│ │ └── generator.go # Tracks IR generation
│ └── go.mod # Depends on zylisp/core
│
├── zast/ # Go AST <-> S-expr
│ ├── codegen/
│ │ └── generator.go # NEW: Generate Go AST with provenance
│ └── go.mod # Depends on zylisp/core
│
├── repl/
│ ├── repl.go # Uses CompilerError for display
│ └── go.mod # Depends on zylisp/core, zylisp/lang
│
└── cli/
├── compile/
│ ├── pipeline.go # Orchestrates compilation with source maps
│ └── go_errors.go # Translates Go compiler errors
└── go.mod # Depends on zylisp/core, zylisp/lang, zylisp/zast
Location in zylisp/core/sourcemap/sourcemap_test.go:
func TestSourceMapChain(t *testing.T) {
// Input layer
inputMap := NewInputSourceMap("parser")
inputMap.RecordOriginal(1, SourceLocation{
File: "test.zl",
Line: 5,
Column: 10,
Length: 8,
})
// Transform layer
transformMap := NewSourceMap("desugar", inputMap)
transformMap.RecordTransform(100, 1)
// Should find original location
loc := transformMap.OriginalLocation(100)
assert.NotNil(t, loc)
assert.Equal(t, "test.zl", loc.File)
assert.Equal(t, 5, loc.Line)
assert.Equal(t, 10, loc.Column)
}
func TestMultiLayerChain(t *testing.T) {
// Simulate full pipeline
inputMap := NewInputSourceMap("parser")
inputMap.RecordOriginal(1, SourceLocation{File: "test.zl", Line: 1, Column: 1})
desugarMap := NewSourceMap("desugar", inputMap)
desugarMap.RecordTransform(10, 1)
macroMap := NewSourceMap("macro", desugarMap)
macroMap.RecordTransform(100, 10)
irMap := NewSourceMap("ir", macroMap)
irMap.RecordTransform(1000, 100)
goMap := NewSourceMap("go-ast", irMap)
goMap.RecordTransform(10000, 1000)
// Should trace all the way back
loc := goMap.OriginalLocation(10000)
assert.NotNil(t, loc)
assert.Equal(t, "test.zl", loc.File)
assert.Equal(t, 1, loc.Line)
}Location in zylisp/cli/compile/pipeline_test.go:
func TestErrorReporting(t *testing.T) {
source := `(defn add [x y] (+ x "hello"))` // Type error
pipeline := NewPipeline(source, "test.zl")
_, err := pipeline.Compile()
require.Error(t, err)
compErr, ok := err.(*errors.CompilerError)
require.True(t, ok)
loc := compErr.Location()
require.NotNil(t, loc)
assert.Equal(t, "test.zl", loc.File)
assert.Equal(t, 1, loc.Line)
// Column should point to the problematic `+` expression
// Error message should reference original source
errMsg := compErr.WithContext(source)
assert.Contains(t, errMsg, "test.zl:1:")
assert.Contains(t, errMsg, source)
}- Create new repository/module
- Implement sourcemap package
- Implement errors package
- Write comprehensive tests
- Add NodeID field to all AST node types
- Update parser to use sourcemap
- Update desugarer to track transformations
- Update macro expander to track transformations
- Update IR generator to track transformations
- Create codegen package
- Generate Go AST with dummy positions
- Track provenance via source maps
- Implement GoErrorTranslator
- Use CompilerError for all error reporting
- Display errors with source context
- Add debug mode for provenance traces
- End-to-end integration tests
- Performance profiling
- Error message quality review
- Documentation
token.Pos is designed for a single-stage compiler working with one FileSet. It breaks down when:
- Serializing/deserializing ASTs
- Multiple transformation stages
- Code generation from non-Go source
Our approach separates concerns:
- Provenance tracking: Via SourceMap (our responsibility)
- Position info: Via token.Pos (Go's printer's responsibility)
Node IDs must be globally unique across all transformation stages so we can trace backward through the chain. Using per-layer IDs would require complex mapping tables.
Only the parser knows the true source positions. All other layers just track transformations. This keeps the system simple and correct.
The Go AST positions are only used by Go's printer for formatting. We don't need them for error tracking. Using dummy positions (0 or token.NoPos) makes it clear we're not relying on them.
Each transformation layer creates:
- A SourceMap struct (~48 bytes + maps)
- Map entries for each transformed node (~16 bytes per entry)
For a 1000-node program through 5 layers:
- ~5000 map entries × 16 bytes = ~80 KB
- 5 SourceMap structs = ~240 bytes
- Total: ~80 KB (negligible)
Looking up original location:
- O(L) where L = number of transformation layers
- For 5 layers: 5 map lookups = ~500 ns
- Only done when reporting errors (rare)
Conclusion: Performance impact is negligible.
Instead of single positions, track ranges:
type SourceRange struct {
Start SourceLocation
End SourceLocation
}This enables better error highlighting and IDE integration.
Some transformations create nodes from multiple sources (e.g., inlining):
type SourceMap struct {
// ...
parentNodes map[NodeID][]NodeID // Multiple parents
}Report all source locations that contributed to the error.
Build a visualization tool that shows the transformation chain:
test.zl:5:10 (defn add [x y])
↓ desugar
node 10
↓ macro-expand
node 100
↓ ir-gen
node 1000
↓ go-ast
return x + y
This source map architecture provides:
- ✅ Accurate error reporting at every compilation stage
- ✅ References to original source code
- ✅ Minimal performance overhead
- ✅ Clean separation of concerns
- ✅ Works with S-expression serialization
- ✅ Scales to complex transformation pipelines
The key insight: Stop fighting token.Pos. Track provenance separately.