Back to top

How the Crystal Compiler Works

Target audience: Developers who already understand Crystal’s type system and general compiler theory (lexing, parsing, type inference, code generation), and want to contribute to Crystal compiler internals.
Goal: Be able to read and modify code under src/compiler/crystal/ on your own.


Table of Contents

  1. Overall Architecture
  2. Directory Structure Reference
  3. AST Node System
  4. Type Hierarchy
  5. Crystal::Program β€” The Central Database of Compilation
  6. Lexing and Parsing
  7. The require System
  8. All Semantic Analysis Passes
  9. Type Inference System (Core)
  10. Macro System
  11. LLVM Code Generation
  12. Contribution Guide

1. Overall Architecture

The Crystal compiler is a self-hosting compiler written in Crystal itself. Compilation is broadly split into three layers: (1) frontend (parse) β†’ (2) middle-end (semantic analysis) β†’ (3) backend (code generation).

flowchart TD
		SRC["πŸ“„ Source code (.cr)"]

		subgraph PARSE ["β‘  Parse"]
				P1["Lexer tokenization"]
				P2["Parser syntax analysis"]
				P3["ASTNode syntax tree"]
				P4["prelude inserted at top"]
				P1 --> P2 --> P3
				P4 -.-> P3
		end

		subgraph SEM ["β‘‘ Semantic  ─  Program#semantic  (semantic.cr)"]
				direction TB
				S1["top level: collect class/def/macro declarations"]
				S2["new methods: auto-generate initialize β†’ new"]
				S3["type declarations: freeze @x : T"]
				S4["abstract def check"]
				S5["restrictions augmenter"]
				S6["ivars / cvars initializers"]
				S7["main: type inference MainVisitor"]
				S8["cleanup: dead code elimination"]
				S1 --> S2 --> S3 --> S4 --> S5 --> S6 --> S7 --> S8
		end

		subgraph CG ["β‘’ Codegen"]
				C1["Generate LLVM IR (module split by type)"]
				C2["LLVM optimization, parallel .o generation"]
				C1 --> C2
		end

		LINK["πŸ”— System linker cc / cl.exe"]
		BIN["βš™οΈ Executable binary"]

		SRC --> PARSE
		PARSE --> SEM
		SEM --> CG
		CG --> LINK --> BIN

The frontend entry point is the Crystal::Compiler class in src/compiler/crystal/compiler.cr. The compile(sources) method invokes parse, semantic analysis, and code generation in sequence.

# compiler.cr β€” implementation of the parse stage
private def parse(program, sources : Array)
	@progress_tracker.stage("Parse") do
		nodes = sources.map { |source| parse(program, source).as(ASTNode) }
		/* Lines 77-81 omitted */
		program.normalize(nodes)
	end
end

With the --progress flag, the compiler prints the current stage and progress to stderr. Combine it with --stats to see elapsed time and memory usage for each stage. This is useful for performance work and bottleneck analysis.


2. Directory Structure Reference

src/compiler/crystal/ β€” Compiler core

Path Responsibility
compiler.cr Crystal::Compiler class. Orchestrates the full compilation pipeline
program.cr Crystal::Program class. Central DB for types, methods, require cache, etc.
types.cr Entire type system definitions (~3600 lines). Inheritance tree rooted at Type
semantic.cr Defines and invokes all semantic passes
crystal_path.cr Filesystem path resolution for require
formatter.cr crystal format code formatter
progress_tracker.cr Compilation stage progress and timing

syntax/ β€” Lexing and parsing

File Responsibility
syntax/lexer.cr Lexer (~2980 lines). Converts source text into Tokens
syntax/token.cr Definitions of all token kinds and keywords
syntax/parser.cr Recursive descent parser (~6500 lines). Inherits from Lexer
syntax/ast.cr All AST node classes (~2500 lines)
syntax/visitor.cr Visitor pattern base class
syntax/transformer.cr Transformer base class for tree-to-tree conversion
syntax/location.cr Source position value object (file, line, column)
syntax/to_s.cr Converts AST nodes back into Crystal source text
syntax/virtual_file.cr Virtual source file management after macro expansion

semantic/ β€” Semantic analysis

File Responsibility
semantic/top_level_visitor.cr Pass 1: declaration handling for class/module/def/macro
semantic/main_visitor.cr Main pass: core type inference (~3700 lines)
semantic/semantic_visitor.cr Shared base class for the two visitors above; also handles require
semantic/bindings.cr Type dependency graph for AST nodes. Uses SmallNodeList observers
semantic/call.cr Call#recalculate β€” core call-site type inference
semantic/method_lookup.cr Overload resolution
semantic/type_merge.cr Union synthesis algorithm for multiple types
semantic/restrictions.cr Evaluates type restrictions like def foo(x : Int32)
semantic/filters.cr Control-flow narrowing via is_a?, nil?, etc.
semantic/cleanup_transformer.cr Dead code elimination and AST post-processing
semantic/type_declaration_processor.cr Handles type declarations like @x : Int32
semantic/abstract_def_checker.cr Verifies implementations of abstract methods
semantic/recursive_struct_checker.cr Detects recursive structs (required because codegen cannot handle them)
semantic/literal_expander.cr Expands literals like [1, 2, 3] into method calls
semantic/exhaustiveness_checker.cr Exhaustiveness checks for case/in

codegen/ β€” LLVM code generation

File Responsibility
codegen/codegen.cr Defines CodeGenVisitor (~2630 lines)
codegen/llvm_typer.cr Crystal type β†’ LLVM type mapping table
codegen/llvm_id.cr Assigns type_id integers to types
codegen/call.cr LLVM IR generation for method calls
codegen/primitives.cr Converts primitives (integer ops etc.) directly to LLVM instructions
codegen/unions.cr Generates tagged-union memory layout for union types
codegen/cast.cr Generates type cast instructions
codegen/debug.cr Generates DWARF debug info
codegen/exception.cr LLVM implementation of rescue/ensure (landingpad)
codegen/target.cr Target architecture/OS configuration
codegen/abi/ ABI implementations: x86_64, aarch64, arm, wasm32, etc.

macros/ β€” Macro system

File Responsibility
macros/interpreter.cr MacroInterpreter < Visitor β€” interpreted execution of macro code
macros/macros.cr Core macro expansion logic
macros/methods.cr Macro methods (.stringify, .instance_vars, etc.)
macros/types.cr Access to type information from within macros

3. AST Node System

Crystal::ASTNode β€” Base class of all nodes

Defined in syntax/ast.cr. Every AST node inherits from this abstract class.

abstract class ASTNode
	property location : Location?       # node start position
	property end_location : Location?   # node end position

	def clone   # deep copy (heavily used in macro expansion)
		clone = clone_without_location
		/* Lines 178-181 omitted */
		clone
	end
end

After semantic analysis, semantic/bindings.cr adds the following:

class ASTNode
	getter dependencies : SmallNodeList = SmallNodeList.new  # dependency nodes
	# @observers is private; access via add_observer/notify_observers
	@type : Type?  # set after semantic analysis
end

ASTNode class diagram

classDiagram
		class ASTNode {
				<<abstract>>
				+location : Location?
				+end_location : Location?
				+type : Type?
				+dependencies : SmallNodeList
				+accept(visitor) void
				+bind_to(node) void
				+clone() ASTNode
				+notify_observers() void
		}
		class Expressions {
				+expressions : Array~ASTNode~
				+keyword : Keyword
		}
		class Nop
		class Var {
				+name : String
		}
		class Call {
				+obj : ASTNode?
				+name : String
				+args : Array~ASTNode~
				+block : Block?
		}
		class Def {
				+name : String
				+args : Array~Arg~
				+body : ASTNode
				+return_type : ASTNode?
		}
		class If {
				+cond : ASTNode
				+then : ASTNode
				+else : ASTNode
		}
		class Assign {
				+target : ASTNode
				+value : ASTNode
		}
		class ClassDef {
				+name : Path
				+body : ASTNode
				+superclass : ASTNode?
		}
		class BinaryOp {
				<<abstract>>
				+left : ASTNode
				+right : ASTNode
		}
		class ControlExpression {
				<<abstract>>
				+exp : ASTNode?
		}
		class Return
		class Break
		class Next

		ASTNode <|-- Expressions
		ASTNode <|-- Nop
		ASTNode <|-- Var
		ASTNode <|-- Call
		ASTNode <|-- Def
		ASTNode <|-- If
		ASTNode <|-- Assign
		ASTNode <|-- ClassDef
		ASTNode <|-- BinaryOp
		ASTNode <|-- ControlExpression
		ControlExpression <|-- Return
		ControlExpression <|-- Break
		ControlExpression <|-- Next

Visitor and Transformer

Visitor (syntax/visitor.cr) is the read-only tree traversal pattern. Implement visit(node : NodeType) to process nodes. Most semantic passes inherit from Visitor.

Transformer (syntax/transformer.cr) is the pattern that returns transformed nodes. transform(node : NodeType) receives a node and returns its transformed replacement. CleanupTransformer, etc., use this.

Dual nature of ASTNode

ASTNode serves both as a syntax node and a typed node, but those roles are separated in time.

After parse (untyped state):
	ASTNode
	/* Lines 288-290 omitted */
	└── @dependencies = []     # dependency graph still empty

After semantic analysis (typed state):
	ASTNode
	/* Lines 294-297 omitted */
	└── @observers = [...]      # nodes observing this node's type changes

Special role of Expressions

Expressions in syntax/ast.cr is the only node representing a Crystal β€œlist of statements” (block), and it underpins most other nodes.

class Expressions < ASTNode
	enum Keyword
		/* Lines 307-313 omitted */
	property keyword : Keyword = Keyword::None

Expressions.from factory method is used throughout the compiler:

# empty array -> Nop.new (do-nothing node)
# one element -> return it directly (do NOT wrap in Expressions)
# two or more elements -> Expressions.new
def self.from(obj : Array)
	case obj.size
	/* Lines 324-327 omitted */
	end
end

Why this matters: in ASTs after require expansion, Expressions.from avoids creating unnecessary wrappers for single-file cases. This improves performance and simplifies downstream visitors.

MainVisitor#visit(node : Expressions) behavior

In Expressions visit, only the last expression carries the resulting type; preceding expressions are evaluated only for side effects:

def visit(node : Expressions)
	exp_count = node.expressions.size
	/* Lines 340-352 omitted */
	false
end

ignoring_type_filters is a mechanism to prevent type-filter side effects (is_a?, etc.) from leaking from intermediate expressions.

FileNode β€” File boundary node

When require is expanded, FileNodes are inserted into Expressions. FileNode stores filename information, and when visited by MainVisitor, it switches @vars and @meta_vars to file-local scopes:

def visit(node : FileNode)
	old_vars = @vars
	/* Lines 365-377 omitted */
	false
end

This prevents top-level variables from one file leaking into others. program.file_module(filename) looks up Program#file_modules, allowing reuse of the same FileModule even when the file is visited multiple times.

Major ASTNode subclass categories

Category Representative nodes Characteristic
Literals NumberLiteral, StringLiteral, BoolLiteral, SymbolLiteral Type is fixed immediately after parse via set_type
References Var, InstanceVar, ClassVar, Global, Path Linked into dependency graph via bind_to
Calls Call recalculate repeatedly infers call types via observer mechanism
Control flow If, While, Return, Yield, Block Node type is determined by merging child types
Declarations Def, ClassDef, ModuleDef, MacroDef Return no meaningful value (Nil type); mutate Program as side effect
Type annotations TypeDeclaration, UninitializedVar Set freeze_type to lock variable type
Conversions Cast (as), IsA (is_a?), NilableCast (as?) Generate type filters and conversion nodes

Basic call contract

Protocol defined in syntax/visitor.cr:

class Visitor
	def visit_any(node) = true   # common pre-hook for all nodes (default: continue)
	def end_visit(node) = nil    # post-hook (default: no-op)
end

class ASTNode
	def accept(visitor)
		/* Lines 409-420 omitted */
	end
end

When visit returns false, child traversal is stopped. Most semantic visit methods manually accept children and then return false, preventing double traversal by default accept_children.

Example Expressions#accept_children:

class Expressions < ASTNode
	def accept_children(visitor)
		/* Lines 431-432 omitted */
	end
end

Declaration nodes like Def return false in TopLevelVisitor to stop walking method bodies. Bodies are traversed only in MainVisitor.

Cross-cutting logic via visit_any

MainVisitor#visit_any:

def visit_any(node)
	@unreachable = false   # reset unreachable flag when entering any node
	super
end

@unreachable becomes true after NoReturn expressions (raise, return, etc.) and is used to skip inference of subsequent expressions. Resetting it in visit_any ensures fresh state at each node entry.

Transformer traversal

Transformer has a traversal mechanism independent from Visitor. ASTNode#transform(transformer) transforms and returns a new node:

class ASTNode
	def transform(transformer)
		/* Lines 458-465 omitted */
	end
end

Example in CleanupTransformer: if an If node is proven to always execute only its then branch (for example, because else is NoReturn), it returns the then content to replace the If node.


4. Type Hierarchy

types.cr (~3600 lines) implements the entire type system. A condensed inheritance tree:

Type (abstract)
β”œβ”€β”€ NoReturnType            # type of expressions that never return a value (raise, exit, etc.)
β”œβ”€β”€ VoidType                # C-like void
β”œβ”€β”€ NilType                 # type of nil
β”œβ”€β”€ BoolType
β”œβ”€β”€ IntegerType
β”‚   β”œβ”€β”€ Int8Type, Int16Type, Int32Type, Int64Type, Int128Type
β”‚   └── UInt8Type, UInt16Type, UInt32Type, UInt64Type, UInt128Type
β”œβ”€β”€ FloatType
β”‚   β”œβ”€β”€ Float32Type
β”‚   └── Float64Type
β”œβ”€β”€ NamedType               # base for named types
β”‚   β”œβ”€β”€ ModuleType
β”‚   β”‚   β”œβ”€β”€ NonGenericModuleType   ← Program inherits from this
β”‚   β”‚   └── GenericModuleType      (e.g. Comparable(T))
β”‚   β”œβ”€β”€ ClassType
β”‚   β”‚   β”œβ”€β”€ NonGenericClassType    (e.g. String, IO)
β”‚   β”‚   └── GenericClassType       (e.g. Array(T), Hash(K,V))
β”‚   β”œβ”€β”€ StructType
β”‚   β”‚   β”œβ”€β”€ NonGenericStructType
β”‚   β”‚   └── GenericStructType      (e.g. Tuple(T*))
β”‚   β”œβ”€β”€ EnumType
β”‚   β”œβ”€β”€ LibType                    # `lib Foo` block
β”‚   └── AliasType                  # `alias Foo = Bar`
β”œβ”€β”€ UnionType               # A | B. Cached in Program#unions
β”œβ”€β”€ PointerInstanceType     # instance-side type for Pointer(T)
β”œβ”€β”€ ProcInstanceType        # instance-side type for Proc(A, B, R)
└── VirtualType             # virtual dispatch type for abstract classes

Type class diagram

classDiagram
		class Type {
				<<abstract>>
				+program : Program
				+to_s() String
				+no_return?() Bool
		}
		class NamedType {
				<<abstract>>
				+name : String
				+locations : Array~Location~
		}
		class ModuleType {
				<<abstract>>
				+types : Hash~String, Type~
				+defs : Hash~String, Array~Def~~
		}
		class ClassType {
				<<abstract>>
				+superclass : ClassType?
				+subclasses : Array~Type~
		}
		class PrimitiveType {
				<<abstract>>
		}
		class NonGenericClassType
		class GenericClassType {
				+type_vars : Array~String~
		}
		class NonGenericModuleType
		class GenericModuleType {
				+type_vars : Array~String~
		}
		class NoReturnType
		class VoidType
		class AliasType {
				+aliased_type : Type
		}
		class UnionType {
				<<abstract>>
				+union_types : Array~Type~
		}
		class MixedUnionType
		class VirtualType {
				+base_type : ClassType
		}
		class EnumType
		class LibType

		Type <|-- NamedType
		Type <|-- UnionType
		Type <|-- VirtualType
		NamedType <|-- ModuleType
		NamedType <|-- ClassType
		NamedType <|-- NoReturnType
		NamedType <|-- VoidType
		NamedType <|-- AliasType
		ModuleType <|-- NonGenericModuleType
		ModuleType <|-- GenericModuleType
		ModuleType <|-- EnumType
		ModuleType <|-- LibType
		ClassType <|-- NonGenericClassType
		ClassType <|-- GenericClassType
		ClassType <|-- PrimitiveType
		UnionType <|-- MixedUnionType
		NonGenericModuleType <|-- Program

VirtualType is especially important. If abstract class Animal has subclasses Dog and Cat, a variable typed as Animal is represented as VirtualType(Animal). In codegen, it is lowered into a tagged union (similar to regular union representation) to enable virtual dispatch.

πŸ“Š Cross-language comparison: Union and Nil safety

Union implementation:

Language Model Runtime representation Safety
Crystal Flat union (A | B | C, singleton per same composition) {type_id: i32, data: union} tagged union β—Ž Full case analysis at compile time
Rust Algebraic data type (enum, named variants) Tagged union (size aligned to max variant) β—Ž Exhaustive match required
TypeScript Structural union (A | B) No runtime tag (duck typing) β–³ Relies on type guards
Haskell ADT (data Either a b = Left a | Right b) Heap pointer with tag β—Ž Exhaustive cases required
C union (untagged) Raw shared memory βœ— Manual management, unsafe

Nil safety implementation:

Language Model Syntax Nature of nil
Crystal Nil is just a type (String | Nil) String? is syntax sugar Unified with union types
Rust Option<T> (Some(T) | None) Option<String> Special case of ADT
Swift Optional<T> String? Special case of ADT
Kotlin Nullable type (String?) String? Built into type system as separate axis
Java (<=10) null exists on all reference types none Outside type system (unsafe)

Crystal’s biggest distinction is that nil is not special-cased at all. String? is only syntax sugar for String | Nil, and Nil is an ordinary NilType class. Since nil safety is provided by the same union machinery, no wrapper like Option<T> is needed.


5. Crystal::Program β€” The Central Database of Compilation

Crystal::Program in program.cr inherits from NonGenericModuleType and stores all global state related to the compilation as a whole.

class Program < NonGenericModuleType
	# all symbols appearing in the program (:foo, :bar, etc.)
	/* Lines 614-630 omitted */
	getter splat_expansions : Hash(Def, Array(Type))
end

Program class diagram

classDiagram
		class Type {
				<<abstract>>
		}
		class NamedType {
				<<abstract>>
				+name : String
		}
		class ModuleType {
				<<abstract>>
				+types : Hash~String, Type~
				+defs : Hash~String, Array~Def~~
				+macros : Hash~String, Array~Macro~~
		}
		class NonGenericModuleType
		class Program {
				+symbols : Set~String~
				+requires : Set~String~
				+file_modules : Hash~String, FileModule~
				+unions : Hash~Array~UInt64~, UnionType~
				+vars : MetaVars
				+filename : String?
				+progress_tracker : ProgressTracker
				+compiler : Compiler?
				+string_pool : StringPool
		}
		class ProgressTracker {
				+stage(name, block)
		}
		class Compiler {
				+compile(sources)
		}

		Type <|-- NamedType
		NamedType <|-- ModuleType
		ModuleType <|-- NonGenericModuleType
		NonGenericModuleType <|-- Program
		Program --> ProgressTracker : progress_tracker
		Program --> Compiler : compiler
		Program --> "*" Type : types[]

Because Program is also the top-level module type, built-in types like Int32 and String are stored as NamedTypes under Program. You can look up types via program.types["String"].


6. Lexing and Parsing

Lexer

Crystal::Lexer in syntax/lexer.cr scans source text with Char::Reader in Unicode units and produces Token objects. It is a pull model: the parser calls next_token on demand, so token streams are not generated all at once.

Important state flags:

Flag Meaning
slash_is_regex Parse / as regex literal start instead of division
doc_enabled Store doc comments beginning with # into Token#doc
wants_def_or_macro_name After def/macro, accept reserved words as names

Parser (recursive descent)

Crystal::Parser in syntax/parser.cr directly inherits from Lexer and consumes tokens through method calls in a classic recursive-descent style (~6500 lines).

class Parser < Lexer
	enum ParseMode
		/* Lines 704-708 omitted */
	end

Variable scope management is the core for identifier/call disambiguation. @var_scopes : Array(Set(String)) stores variable-name sets as a stack. When an identifier is read, if its name exists in the current scope set, it is parsed as a variable reference; otherwise as a zero-arg method call.

foo   # if "foo" exists in @var_scopes -> Var.new("foo")
foo   # if "foo" does not exist in @var_scopes -> Call.new(nil, "foo", [])

Entry flow is parse β†’ parse_expressions β†’ parse_XXX methods. Output is an ASTNode tree.

πŸ“Š Cross-language comparison: Lexer/Parser implementation style

Language Lexer Parser Notes
Crystal Handwritten DFA (case current_char) Handwritten recursive descent Context-sensitive decisions via @var_scopes stack
Rust Handwritten DFA Handwritten recursive descent Resolves contextual < ambiguity by surrounding context
Go Handwritten DFA Handwritten recursive descent Minimal context dependence due to simple grammar
Clang Handwritten DFA Handwritten recursive descent Handles C/C++ context-dependent syntax via scope analysis
Ruby (<=3.1) flex (lexer generator) Yacc/Bison (LR(1)) Declarative grammar but harder error messages
Ruby (3.2+) Handwritten LRAMA (LR, custom) Migrated from Yacc to custom LR generator
Python (3.9+) Handwritten PEG/Packrat (pegen) Uses memoization for backtracking
GHC (Haskell) Alex (flex-like) Happy (LALR) Symbol-heavy ambiguity managed at grammar level

Crystal’s core reason for choosing handwritten recursive descent is context sensitivity. Whether foo is a variable or method call requires @var_scopes. Whether { starts a block or hash literal depends on call context. These are difficult to express naturally in declarative LR/PEG grammars, while handwritten code can directly control them through ParseMode and flags like @stop_on_do.


7. The require System

File lookup: CrystalPath

Crystal::CrystalPath in crystal_path.cr resolves require "foo".

Search path priority:

  1. CRYSTAL_PATH environment variable β€” :-separated directory list
  2. Crystal::Config.path β€” path embedded at compiler build time
  3. lib/ β€” libraries installed by shards

$ORIGIN expansion: For install portability, a special indirection $ORIGIN is used to reference stdlib relative to the compiler binary location.

require "./foo" resolves relative to current file. require "foo" uses path search.

Duplicate-prevention for require

Handled by visit(node : Require) in semantic/semantic_visitor.cr:

def visit(node : Require)
	filenames = @program.find_in_path(node.string, relative_to_filename)
/* Lines 760-768 omitted */
	end
end

Program#requires is a Set(String) of absolute paths, so requiring the same file multiple times still processes it only once.

Prelude bootstrap

Compiler#parse inserts Require.new("prelude") at the beginning of all source inputs. prelude points to src/prelude.cr, which then recursively requires object.cr, value.cr, int.cr, string.cr, array.cr, etc., defining all built-in Crystal types.


8. All Semantic Analysis Passes

Program#semantic in semantic.cr runs all passes in sequence. Each stage is wrapped with @progress_tracker.stage("Semantic (xxx)") { ... }.

def semantic(node : ASTNode, cleanup = true, ...) : ASTNode
	node, processor = top_level_semantic(node)         # passes 1–5
/* Lines 787-807 omitted */
	result
end

Early passes invoked from top_level_semantic:

def top_level_semantic(node, ...)
	# pass 1: TopLevelVisitor β€” register class/def/macro/alias/include declarations
	/* Lines 816-838 omitted */
	end
end

Responsibility of each pass

Pass Class Summary
top level TopLevelVisitor Processes only declarations of class/module/def/macro; does not instantiate types
new define_new_methods Auto-generates self.new(...) for each initialize overload
type declarations TypeDeclarationProcessor Collects explicit type declarations (@x : Int32) and sets freeze type (freeze_type)
abstract def check AbstractDefChecker Verifies every concrete class implements abstract methods
restrictions augmenter RestrictionsAugmenter Adds implicit type restrictions to overload candidates for faster dispatch
ivars initializers InstanceVarsInitializerVisitor Associates instance var initializers (@x = 1) with class type info
cvars initializers ClassVarsInitializerVisitor Resolves global initialization order for class vars (@@x = 1)
main MainVisitor Core type inference; types all expressions and instantiates methods
cleanup CleanupTransformer Removes branches reduced to NoReturn, expands single-type unions
recursive struct RecursiveStructChecker Detects recursive structs with infinite size

Full pipeline chain

Crystal::Compiler#compile
β”‚
β”œβ”€ parse(sources)
β”‚   └─ Parser#parse β†’ ASTNode
β”‚       └─ Lexer#next_token (pull model)
β”‚
└─ Program#semantic(node)
		β”‚
		/* Lines 870-917 omitted */
		└─ RecursiveStructChecker#run                [pass 10]

Recursive parsing during require

When a require node is reached, SemanticVisitor (base of TopLevelVisitor and MainVisitor) immediately reads/parses/traverses that file. This is not lazy; it is immediate expansion.

semantic/semantic_visitor.cr:

def visit(node : Require)
	# ... skip if already expanded
	/* Lines 929-948 omitted */
	false
end

private def require_file(node : Require, filename : String)
	parser = @program.new_parser(File.read(filename))  # <- create Lexer + Parser
	/* Lines 953-961 omitted */
	FileNode.new(parsed_nodes, filename)  # <- wrap and return as FileNode
end

Because parse + traverse happens inside the current visitor stack frame, require behaves effectively as inline expansion. On error, nested messages such as β€œwhile requiring ...” are shown.

All places where MainVisitor creates another MainVisitor

Location Constructor call Purpose
Method instantiation MainVisitor.new(program, vars, typed_def) Type inference for method body; typed_def tracks current method
Block MainVisitor.new(program, before_block_vars, @typed_def, meta_vars) Block-local scope so block params don't overwrite outer vars
Constant MainVisitor.new(@program, meta_vars, const_def) Type inference for const initializer; managed with inside_constant = true
Closure MainVisitor.new(program, fun_vars, node.def, meta_vars) Body of function literals (-> { }, etc.)
Interpreter MainVisitor.new(from_main_visitor: @main_visitor) REPL session continuation; inherits vars / meta_vars

Each new MainVisitor has its own vars map, but shares the same Program. Therefore, observers attached by different visitors still propagate through the same graph via notify_observers.

Data written by TopLevelVisitor

Main writes to Program (and each NamedType):

# β‘  create class/struct/module
type = NonGenericClassType.new(@program, scope, name, superclass, false)
scope.types[name] = type                   # register in Program#types

# β‘‘ register method definition
def visit(node : Def)
	node.owner = @current_type
	/* Lines 993-996 omitted */
	false   # <- do not enter body
end

# β‘’ register macro
def visit(node : MacroDef)
	@current_type.add_macro(node)
	/* Lines 1002-1003 omitted */
	false
end

# β‘£ process include/extend
def visit(node : Include)
	included_module = lookup_type(node.name)
	/* Lines 1009-1010 omitted */
	# added to @current_type.parents
end

add_def overload management (types.cr):

def add_def(a_def)
	a_def.owner = self
	/* Lines 1019-1034 omitted */
	list << item   # least strict -> append at tail
end

defs is Hash(String, Array(DefWithMetadata)), an overload list indexed by method name. DefWithMetadata wraps a Def node plus restriction metadata (strictness of arg constraints).

Data written by TypeDeclarationProcessor

# process @x : Int32 -> set freeze_type on ivar
ivar = type.lookup_instance_var(name)
ivar.freeze_type = declared_type    # assignment of other types becomes error

# @@x : Int32 -> class var
class_var = type.lookup_class_var(name)
class_var.freeze_type = declared_type

When freeze_type is set, ASTNode#set_type enforces the constraint and raises FrozenTypeException on mismatch.

Data written by MainVisitor

MainVisitor types every ASTNode, but writes to Program itself mostly at these points:

Destination Code Timing
Node#type node.set_type(t) / node.bind_to(other) During each node visit
Def#vars (typed_def.vars) meta_vars = typed_def.vars = MetaVars.new During method instantiation
Program#vars @meta_vars = @program.vars (for top-level) On initialization
FileModule#vars @meta_vars = file_module.vars During FileNode visit
Program#const_initializers program.const_initializers << type During const visits
DefInstanceContainer#def_instances type.add_def_instance(key, typed_def) Cache on method instantiation
Symbol table @program.symbols << symbol_name During :foo literal visit

DefInstanceContainer β€” method-instantiation cache

To avoid repeatedly inferring the same method for the same argument type combination, DefInstanceContainer in types.cr provides a cache:

# cache key: (original Def object id, arg type array, block type, named arg types)
record DefInstanceKey,
	def_object_id : UInt64,
	/* Lines 1076-1078 omitted */
	named_args : Array(NamedArgumentType)?

module DefInstanceContainer
	getter(def_instances) { {} of DefInstanceKey => Def }
/* Lines 1082-1089 omitted */
	end
end

A typed Def is a clone of the original Def node with type info added (and generic params substituted with concrete types when needed). Same signature β†’ same typed Def reused, so even 1000 calls to main infer only once.

SemanticVisitor#current_type β€” type context stack

SemanticVisitor has @current_type : ModuleType to track current class/module context during traversal:

abstract class SemanticVisitor < Visitor
	property current_type : ModuleType  # initial value is Program itself
/* Lines 1102-1108 omitted */
	end
end

visit(ClassDef) enters via pushing_type(node.resolved_type), so inner def/include declarations are registered against the correct class as @current_type.


9. Type Inference System (Core)

Dependency graph and type propagation

Crystal’s inference works like Andersen-style data-flow propagation over a dependency graph.

Each ASTNode is extended in semantic/bindings.cr and becomes a node in the dependency graph:

struct SmallNodeList
	# stores first two elements inline; additional ones in @tail Array
	/* Lines 1127-1132 omitted */
	...
end

class ASTNode
	getter dependencies : SmallNodeList  # nodes this node depends on
	/* Lines 1137-1138 omitted */
	@type : Type?
end

Propagation mechanism:

  1. bind_to(node) β€” sets both self.dependencies << node and node.observers << self
  2. type= β€” when @type changes, calls notify_observers
  3. notify_observers β€” recursively calls each observer’s update_type, cascading changes
def type=(type)
	return if @type.same?(type) || (!type && !@type)
	/* Lines 1151-1153 omitted */
	@type
end

def bind_to(node : ASTNode) : Nil
	bind(node) do
		/* Lines 1158-1160 omitted */
	end
end

Dual tracking: vars and meta_vars

MainVisitor tracks variable types in two dictionaries:

class MainVisitor < SemanticVisitor
	# vars: variable type at current execution point (single/concrete)
	/* Lines 1171-1176 omitted */
	getter meta_vars : MetaVars
end

vars is snapshotted across control flow (if/while/rescue, etc.) and merged after branching. meta_vars accumulates across the entire method and becomes final local declaration types.

Control-flow narrowing (type narrowing)
Implemented in semantic/filters.cr. In if x.is_a?(Int32), x is narrowed to Int32 in then-branch and to β€œoriginal minus Int32” in else-branch. This is done by storing TypeFilter in vars[name].filter, then applying it in MainVisitor#visit(Var).

Call#recalculate β€” core of call-site inference

In semantic/call.cr, recalculate is rerun whenever callee type changes. It is automatically triggered by observer propagation in the dependency graph.

def recalculate
	obj = @obj
	/* Lines 1192-1210 omitted */
	# -> bind_to return value node to establish type propagation
end

type_merge β€” union synthesis

semantic/type_merge.cr merges multiple types into unions. Two-type merge is optimized because it is the most frequent case:

def type_merge(first : Type?, second : Type?) : Type?
	return first if first == second   # same type -> return either
	/* Lines 1221-1227 omitted */
	combined_union_of compact_types({first, second})
end

def compact_types(objects, &) : Array(Type)
	all_types = Array(Type).new(objects.size)
	/* Lines 1232-1235 omitted */
	all_types
end

add_type recursively flattens UnionType, so (A | B) | C normalizes to A | B | C. Program#unions caches unions so identical compositions are represented by a single object.

πŸ“Š Cross-language comparison: type inference strategy

Language Strategy Annotation at function boundary Global inference
Crystal Data-flow propagation (observer pattern) Unnecessary (return types inferred too) β—Ž Yes
Haskell / ML Hindley–Milner (constraint unification) Unnecessary β—Ž Yes
Rust Bidirectional local inference Required (function signatures explicit) β–³ Mostly intra-function
TypeScript Bidirectional + structural typing Optional (falls back to any) β–³ Limited
Go Declaration-time only (:=) Required βœ— Almost none
Java / C# Local vars only (var/local) Required βœ— None

Crystal does not use an HM-style global constraint solver; instead, it uses observer-based propagation over AST dependency graph nodes. If return type of foo() changes later, type of x in x = foo() is automatically recalculated.


10. Macro System

Concept

Crystal macros are expanded at AST level during compilation. They are not runtime metaprogramming. Macro calls are detected by TopLevelVisitor or MainVisitor, then evaluated by MacroInterpreter.

Behavior of MacroInterpreter

MacroInterpreter < Visitor in macros/interpreter.cr is a VM that interprets macro AST. It does not compile to machine code; instead, it visits AST directly and writes output text into @str : IO::Memory.

Expansion flow:

β‘  TopLevelVisitor detects macro call `Call`
β‘‘ Create MacroInterpreter.new(macro_def, call_args)
	 - bind arguments into @vars : Hash(String, ASTNode)
β‘’ Evaluate macro_def.body via visitor.visit()
	 - {{ var }}  -> fetch from @vars and .to_s
	 /* Lines 1274-1275 omitted */
	 - {% for x in items %} -> expand loop over items
β‘£ Re-parse @str contents as Crystal source -> get ASTNode
β‘€ Insert resulting ASTNode at original macro call site
β‘₯ Record expanded source in VirtualFile (for error line mapping)

Macro methods (introspection)

macros/methods.cr implements special methods callable inside macros:

Macro method Return value Notes
@type.instance_vars ArrayLiteral names/types of all instance vars
@type.methods ArrayLiteral all method definitions
@type.subclasses ArrayLiteral direct subclasses
@type.ancestors ArrayLiteral ancestor types
@type.has_method?(:foo) BoolLiteral method existence check
system("cmd") StringLiteral run shell command at compile time
run("path.cr", args) StringLiteral compile+run external Crystal program
env("VAR") StringLiteral or NilLiteral read environment variable

@type is the type at macro expansion site context. Inside instance methods, it refers to receiver type; inside class methods, class type.

πŸ“Š Cross-language comparison: macro systems

Language Model Execution timing Input/Output
Crystal AST macro (interpreted by MacroInterpreter) Semantic phase (type info accessible) Crystal source string β†’ reparse
Rust macro_rules! + proc macro After parse, before typing (proc macro is pre-type) TokenStream β†’ TokenStream
C Text substitution preprocessor Before parse Text β†’ Text (no type info)
Lisp / Clojure Homoiconic macro (code=data) After read, before eval S-expression β†’ S-expression
Nim Typed AST macros Semantic phase Typed AST β†’ Typed AST
Haskell Template Haskell After type checking AST construction in Q monad

Crystal’s standout point is type-info access plus string-based output. You can inspect semantic info via @type.instance_vars, @type.methods, @type.subclasses while generating code. Output is Crystal source text, reparsed and re-analyzed, unlike systems that directly construct AST/token structures.


11. LLVM Code Generation

CodeGenVisitor

CodeGenVisitor in codegen/codegen.cr inherits from Visitor and transforms typed AST into LLVM IR.

Runtime functions expected by compiler (implemented in libcrystal):

MAIN_NAME              = "__crystal_main"        # generated entry point
RAISE_NAME             = "__crystal_raise"       # throw exception
RAISE_OVERFLOW_NAME    = "__crystal_raise_overflow"
RAISE_CAST_FAILED_NAME = "__crystal_raise_cast_failed"  # as! failure
MALLOC_NAME            = "__crystal_malloc64"    # GC heap allocation
MALLOC_ATOMIC_NAME     = "__crystal_malloc_atomic64"     # for non-pointer data
REALLOC_NAME           = "__crystal_realloc64"
ONCE_INIT              = "__crystal_once_init"   # class var once-initialization

Multi-module compilation

By default, Crystal generates separate LLVM::Modules per type and compiles in parallel (up to 8 threads). With --release or --single-module, all code is merged into one LLVM::Module to benefit from LTO.

LLVMTyper β€” Crystal type β†’ LLVM type mapping

LLVMTyper in codegen/llvm_typer.cr performs type lowering.

String layout:

def llvm_string_type(bytesize)
	@llvm_context.struct [
		/* Lines 1345-1349 omitted */
	]
end

Proc layout: two-element tuple of closure data and function pointer

def proc_type
	@llvm_context.struct [
		/* Lines 1358-1360 omitted */
	], "->"
end

Nil layout: empty struct (zero bytes)

def nil_type
	@llvm_context.struct([] of LLVM::Type, "Nil")
end

Handling recursive types: @wants_size_cache and @wants_size_struct_cache prevent infinite recursion during size computation. For example, while sizing struct Foo containing Pointer(Int32 | Foo), pointer size is approximated as machine word size during union sizing.

Union memory layout

Implemented in codegen/unions.cr as tagged union:

{ i32 type_id, [N x i8] data }

Dispatch reads type_id, branches, bitcasts to concrete type, then loads. VirtualType uses the same representation.

Primitive operations

codegen/primitives.cr directly lowers integer/floating-point ops and pointer ops into LLVM instructions. For example, Int32#+ becomes LLVM add nsw i32 (nsw allows UB on signed overflow to help optimization).

ABI implementations

codegen/abi/ contains C calling convention logic for each target. With Crystal lib interop, argument and return register placement differs per architecture; this is handled by ABI layer.

File Target
abi/x86_64.cr Linux/macOS x86-64 (System V AMD64 ABI)
abi/x86_win64.cr Windows x86-64 (Microsoft x64 ABI)
abi/aarch64.cr ARM64 (AARCH64 Procedure Call Standard)
abi/arm.cr ARM 32-bit
abi/wasm32.cr WebAssembly 32-bit
abi/avr.cr AVR microcontrollers

Splitting unit

Default mode (without --single-module) splits by type into LLVM modules. CodeGenVisitor#type_module(type) controls mapping:

def type_module(type)
	return @main_module_info if @single_module   # all-in-one with --single-module
/* Lines 1413-1431 omitted */
	end
end

@modules : Hash(String, ModuleInfo) stores all modules keyed by type name string (empty string key = main module).

@types_to_modules : Hash(Type, ModuleInfo) caches type-object to module mapping, ensuring repeated type_module calls for the same type return the same ModuleInfo.

ModuleInfo contents

record ModuleInfo,
	mod : LLVM::Module,           # LLVM module body
	/* Lines 1444-1445 omitted */
	builder : CrystalLLVMBuilder  # LLVM IR builder

Each module has its own LLVM::Context. This is crucial for parallel safety because LLVM contexts are not thread-safe.

Parallel compile and bitcode relay

On main thread:
	each type -> CodeGenVisitor.visit() -> LLVM::Module (in memory)

In parallel (n_threads workers / processes):
	each CompilationUnit:

LLVM IR generation is single-threaded (shared global LLVM context), while optimization and object emission are parallelized.

Implementation differs between --preview_mt (thread model) and normal (fork model):

private def parallel_codegen(units, n_threads)
	{% if flag?(:preview_mt) %}
		/* Lines 1471-1478 omitted */
	{% end %}
end

In fork_codegen, child processes run in isolated address spaces, avoiding LLVM context conflicts. In mt_codegen, unit.generate_bitcode runs single-threaded (shared context), then units are sent via channel and each worker runs unit.compile(isolate_context: true) after reparse into isolated context.

File cache and incremental compile

Compilation outputs are cached under CacheDir.instance.directory_for(sources). Each CompilationUnit caches both .bc (bitcode) and .o files, and reuses .o for unchanged type modules:

Output cache layout:
~/.cache/crystal/<hash>/
β”œβ”€β”€ String.bc                 # bitcode for String type
β”œβ”€β”€ String.o                  # object file for String type
β”œβ”€β”€ Array(Int32).bc
β”œβ”€β”€ Array(Int32).o
β”œβ”€β”€ (main).bc                 # top-level code
β”œβ”€β”€ (main).o
└── bc_flags                  # compiler-flag cache key

bc_flags_changed? detects compiler flag changes (target, opt level, etc.) and triggers full rebuild if changed. Even with same flags, if .bc hash differs, only affected modules are recompiled.

πŸ“Š Cross-language comparison: parallelization unit

Language Parallelization unit Invalidation scope when unit changes Cache granularity
Crystal Type (String, Array(Int32), etc.) Only .o files with methods on that type Per-type .bc / .o
Rust Crate (Cargo.toml unit) Entire crate recompiles Per-crate .rlib
Go Package (directory unit) Entire package recompiles Per-package .a
C / C++ Translation unit (.c / .cpp) That file only (headers can propagate broadly) Object file
Swift Module + file Module-wide (under WMO) Per-module
Java / Kotlin Class file (.class) Dependency chain may propagate .class file

Crystal’s type-level unit is unique among mainstream languages. Adding a method on String does not require recompiling Array(Int32).o if unchanged.

End-to-end transform flow in CodeGenVisitor

CodeGenVisitor in codegen/codegen.cr converts typed AST to LLVM IR. Since semantic analysis already fixed all node types, codegen mostly reads type metadata.

flowchart TD
		A["Typed ASTNode tree"]
		B["CodeGenVisitor#accept(node)"]
		C{"Node type"}
		D["visit(Assign)
store instruction"]
		E["visit(If)
br + phi"]
		F["visit(Call)
codegen_call"]
		G["visit(While)
loop basic blocks"]
		H["visit(NumberLiteral)
LLVM::Value constant"]
		I["inside codegen_call"]
		J{"Receiver type"}
		K["visit_primitive
primitive instructions
(add nsw, fadd, etc.)"]
		L["codegen_dispatch
type_id switch
virtual dispatch"]
		M["normal call instruction
single concrete type"]
		N["LLVM::Module
(split by type)"]

		A --> B --> C
		C --> D & E & F & G & H
		F --> I --> J
		J -->|primitive| K
		J -->|Union/Virtual| L
		J -->|single type| M
		D & E & G & H & K & L & M --> N

For each visited node, the resulting value is set into @last : LLVM::Value; parent generation reads from @last.

LLVM IR examples for major nodes

Assign β†’ store

# Crystal: x = 42
# ↓
def visit(node : Assign)
	target, value = node.target, node.value
	/* Lines 1568-1571 omitted */
	store val, ptr                       # LLVM store
end

Generated IR:

%x = alloca i32
store i32 42, i32* %x

If β†’ br + phi

# Crystal: y = x > 0 ? x : -x
# ↓
def visit(node : If)
	node.cond.accept self
	/* Lines 1588-1606 omitted */
	@last = phi(llvm_type(node.type), {then_val => then_block, else_val => else_block})
end

While β†’ loop basic blocks

; Crystal: while i < 10; i += 1; end
entry:
	br label %while_cond

while_cond:
	%cmp = icmp slt i32 %i, 10
	br i1 %cmp, label %while_body, label %while_exit

while_body:
	; body for i += 1
	br label %while_cond

while_exit:
	; following code

While creates while_cond, while_body, and while_exit, connected by cond_br/br.

Virtual dispatch generation (VirtualType / Union)

Handled by codegen_dispatch in codegen/call.cr. When receiver is VirtualType or UnionType, it emits LLVM switch over type_id:

; call meow on Animal (VirtualType)
%type_id = getelementptr %Animal, %Animal* %recv, i32 0, i32 0
%tid = load i32, i32* %type_id
switch i32 %tid, label %dispatch_fail [
	i32 1, label %call_Dog_meow
	i32 2, label %call_Cat_meow
]

call_Dog_meow:
	call void @"*Dog#meow<Dog>"(%Dog* %recv_cast)
	br label %join
call_Cat_meow:
	call void @"*Cat#meow<Cat>"(%Cat* %recv_cast)
	br label %join

If type is concrete (or only one overload match), switch is skipped and a direct call is emitted.

Closures and Proc literals

Proc literals (-> { }, method refs) are handled in visit(node : ProcLiteral) of codegen/codegen.cr.

Memory layout: Proc is two-word struct {void* closure_data, void* func_ptr} (LLVMTyper#proc_type).

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ closure_data ptr  β”‚  func_ptr        β”‚
β”‚ (8 bytes)         β”‚  (8 bytes)       β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Closure data heap allocation: if captures exist, codegen allocates closure struct with __crystal_malloc64 and copies captured values:

; closure generation for -> { puts x } (captures x)
%closure = call i8* @__crystal_malloc64(i64 8)      ; space for x
%x_slot  = getelementptr i8, i8* %closure, i64 0    ; x field in closure
store i64 %x_val, i64* %x_slot                      ; copy current value

%proc.0 = insertvalue { i8*, i8* } undef, i8* %closure, 0
%proc.1 = insertvalue { i8*, i8* } %proc.0, i8* @"~closure_0", 1
; -> { closure_data=..., func_ptr=@~closure_0 }

Closure call: on proc.call(args), extract function pointer and pass closure data as implicit first argument:

%fp  = extractvalue { i8*, i8* } %proc, 1    ; function pointer
%env = extractvalue { i8*, i8* } %proc, 0    ; closure data
call i32 %fp(i8* %env, ...)                   ; insert env as first arg

For capture-free procs, closure_data is just null; layout stays the same.


12. Contribution Guide

Quick file map by task

What you want to do Main files to edit
Add a new keyword syntax/token.cr β†’ syntax/lexer.cr β†’ syntax/parser.cr
Add new syntax/AST node syntax/ast.cr (node def) β†’ syntax/visitor.cr (add visit method) β†’ implement in each visitor
Change type-check rule semantic/main_visitor.cr (visit(NodeType) methods)
Change overload resolution semantic/method_lookup.cr, semantic/restrictions.cr
Add new builtin primitive op codegen/primitives.cr (visit_primitive)
Add macro method macros/methods.cr
Add compiler option compiler.cr (property) β†’ command/compile.cr (OptionParser)
Change type internal representation update both types.cr and codegen/llvm_typer.cr
Add architecture support new file under codegen/abi/ β†’ register in codegen/target.cr
Improve error message locate raise site with grep and edit in-place

Spec layout and execution

Tests under spec/compiler/ mirror src/compiler/crystal/ structure:

spec/compiler/
β”œβ”€β”€ spec_helper.cr       # shared helpers (assert_type, assert_error, etc.)
β”œβ”€β”€ lexer/               # tokenization unit tests
β”œβ”€β”€ parser/              # AST structure checks
β”œβ”€β”€ semantic/            # type inference and type error checks
β”œβ”€β”€ codegen/             # runtime result checks of generated code
β”œβ”€β”€ macro/               # macro expansion checks
β”œβ”€β”€ normalize/           # AST normalization tests
└── formatter/           # formatter tests

Commands:

# all compiler specs (slow)
make spec/compiler

# specific file only
bin/crystal spec spec/compiler/semantic/proc_spec.cr

# specific test case by name filter
bin/crystal spec spec/compiler/semantic/ -e "proc type"

# show compiler progress
bin/crystal build src/compiler/crystal.cr --progress

# show per-stage timings and memory usage
bin/crystal build src/compiler/crystal.cr --stats

assert_type("x = 1") in spec_helper.cr checks inferred expression type. assert_error("...", "message") checks compiler error messages. Small focused specs using these helpers are expected in pull requests.

Debugging tips


Summary

The Crystal compiler is powered by tightly integrated layers:

  1. Lexer/Parser β€” a 6500-line handwritten recursive-descent parser resolving ambiguity with variable-scope stack and ParseMode.
  2. Program β€” shared object centralizing types, methods, require cache, etc.
  3. Semantic (multi-stage) β€” declaration collection β†’ type declaration processing β†’ type inference β†’ cleanup. Inference propagates through dependency graph with observer pattern.
  4. MacroInterpreter β€” VM that directly interprets AST for compile-time code generation.
  5. CodeGenVisitor + LLVMTyper β€” converts typed AST to LLVM IR. Unions use tagged representation {type_id, data}; procs use closure-pointer pairs.
  6. accept/visit protocol β€” foundation of all visitors/transformers. Returning false from visit controls child traversal; require is immediate inline parse+walk in current semantic stack frame.
  7. Type-data mutation flow β€” TopLevelVisitor builds Program#types, Type#defs, Type#macros; MainVisitor fills each ASTNode#type and Def#vars to form complete typed AST needed by codegen.
  8. Parallel compilation β€” type-level LLVM module splitting plus .o cache enables true incremental recompilation of only changed types.