Repository: benhoyt/goawk
Pull request #148 (2022-08-30 22:42:55)

Decouple parse & resolve steps
This PR is prepared in context of #144.

It implements the part

> However, that would require separating out the resolver to a separate pass ... which is probably good to do anyway, but a bit of work.

Since the change is reasonably big I would add couple clarifications.

1. I tried to do only minimal work to get the task in question done, though I could not resist some (hopefully) minor refactorings and restructuring. 
2. It appears that resolving step needs some positions information, but not for all nodes. So at this point to keep the things simple and limited I wired the position only through very few of AST nodes. If need be we can equip all AST nodes with positions in generic way later but no need at this point.
3. I decided that we don't need to move ALL things that were in resolve.go to separate step. To me some things logically belong more to parsing than resolution. Example: `multiExprs` thing.
4. Since the parsing & resolving needed the same approach to errors with positions I generified this a bit by introducing the `PositionError` which is now panic-thrown internally by both parser & resolver. However I kept the external parser interface intact to return documented `ParseError`.
5. I checked the test suite - seems to work fine. 

benhoyt: Excellent, thanks for this. I'll review it -- hopefully over the next week or so. Note that I just merged another change, so there's a small conflict in `parser.go` which needs fixing.
xonixx: > Note that I just merged another change, so there's a small conflict in parser.go which needs fixing.

Merged master.
xonixx: @benhoyt Any update?
benhoyt: Not yet, sorry. I glanced at it briefly, but am now traveling overseas. It
might be another week or two, but I will get to this.

On Fri, Sep 9, 2022, 7:10 AM Volodymyr Gubarkov ***@***.***>
wrote:

> @benhoyt <https://github.com/benhoyt> Any update?
>
> —
> Reply to this email directly, view it on GitHub
> <https://github.com/benhoyt/goawk/pull/148#issuecomment-1241236690>, or
> unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AAHT46NFKQDOL7BWMETUFHDV5JI4HANCNFSM6AAAAAAQA2YQ2U>
> .
> You are receiving this because you were mentioned.Message ID:
> ***@***.***>
>

xonixx: Sure no problem. Have a pleasant trip!
xonixx: Hey @benhoyt! Hopefully, I've resolved all items except for the one with `WalkExprList`/`WalkStmtList` - please see my comment there. What do you think?
xonixx: Done.

Patch:
diff --git a/internal/ast/ast.go b/internal/ast/ast.go
index 8232765..de19ce3 100644
--- a/internal/ast/ast.go
+++ b/internal/ast/ast.go
@@ -13,11 +13,17 @@ import (
 // Program is an entire AWK program.
 type Program struct {
 	Begin     []Stmts
-	Actions   []Action
+	Actions   []*Action
 	End       []Stmts
-	Functions []Function
-	Scalars   map[string]int
-	Arrays    map[string]int
+	Functions []*Function
+}
+
+// ResolvedProgram is a parsed AWK program + additional data prepared by resolve step
+// needed for subsequent interpretation
+type ResolvedProgram struct {
+	Program
+	Scalars map[string]int
+	Arrays  map[string]int
 }
 
 // String returns an indented, pretty-printed version of the parsed
@@ -75,8 +81,54 @@ func (a *Action) String() string {
 	return strings.Join(patterns, ", ") + sep + stmtsStr
 }
 
+// Node is an interface to be satisfied by all AST elements.
+// We need it to be able to work with AST in a generic way, like in ast.Walk().
+type Node interface {
+	node()
+}
+
+// All these types implement the Node interface.
+func (p *Program) node()        {}
+func (a *Action) node()         {}
+func (f *Function) node()       {}
+func (e *FieldExpr) node()      {}
+func (e *NamedFieldExpr) node() {}
+func (e *UnaryExpr) node()      {}
+func (e *BinaryExpr) node()     {}
+func (e *ArrayExpr) node()      {}
+func (e *InExpr) node()         {}
+func (e *CondExpr) node()       {}
+func (e *NumExpr) node()        {}
+func (e *StrExpr) node()        {}
+func (e *RegExpr) node()        {}
+func (e *VarExpr) node()        {}
+func (e *IndexExpr) node()      {}
+func (e *AssignExpr) node()     {}
+func (e *AugAssignExpr) node()  {}
+func (e *IncrExpr) node()       {}
+func (e *CallExpr) node()       {}
+func (e *UserCallExpr) node()   {}
+func (e *MultiExpr) node()      {}
+func (e *GetlineExpr) node()    {}
+func (s *PrintStmt) node()      {}
+func (s *PrintfStmt) node()     {}
+func (s *ExprStmt) node()       {}
+func (s *IfStmt) node()         {}
+func (s *ForStmt) node()        {}
+func (s *ForInStmt) node()      {}
+func (s *WhileStmt) node()      {}
+func (s *DoWhileStmt) node()    {}
+func (s *BreakStmt) node()      {}
+func (s *ContinueStmt) node()   {}
+func (s *NextStmt) node()       {}
+func (s *ExitStmt) node()       {}
+func (s *DeleteStmt) node()     {}
+func (s *ReturnStmt) node()     {}
+func (s *BlockStmt) node()      {}
+
 // Expr is the abstract syntax tree for any AWK expression.
 type Expr interface {
+	Node
 	expr()
 	String() string
 }
@@ -154,6 +206,7 @@ type ArrayExpr struct {
 	Scope VarScope
 	Index int
 	Name  string
+	Pos   Position
 }
 
 func (e *ArrayExpr) String() string {
@@ -221,6 +274,9 @@ func (e *RegExpr) String() string {
 	return "/" + escaped + "/"
 }
 
+// meaning it will be set during resolve step
+const resolvedLater = -1
+
 type VarScope int
 
 const (
@@ -236,6 +292,7 @@ type VarExpr struct {
 	Scope VarScope
 	Index int
 	Name  string
+	Pos   Position
 }
 
 func (e *VarExpr) String() string {
@@ -315,6 +372,7 @@ type UserCallExpr struct {
 	Index  int
 	Name   string
 	Args   []Expr
+	Pos    Position
 }
 
 func (e *UserCallExpr) String() string {
@@ -375,6 +433,7 @@ func IsLValue(expr Expr) bool {
 
 // Stmt is the abstract syntax tree for any AWK statement.
 type Stmt interface {
+	Node
 	stmt()
 	String() string
 }
@@ -585,6 +644,7 @@ type Function struct {
 	Params []string
 	Arrays []bool
 	Body   Stmts
+	Pos    Position
 }
 
 func (f *Function) String() string {
@@ -598,3 +658,38 @@ func trimParens(s string) string {
 	}
 	return s
 }
+
+// VarRef is a constructor for *VarExpr
+func VarRef(name string, pos Position) *VarExpr {
+	return &VarExpr{resolvedLater, resolvedLater, name, pos}
+}
+
+// ArrayRef is a constructor for *ArrayExpr
+func ArrayRef(name string, pos Position) *ArrayExpr {
+	return &ArrayExpr{resolvedLater, resolvedLater, name, pos}
+}
+
+// UserCall is a constructor for *UserCallExpr
+func UserCall(name string, args []Expr, pos Position) *UserCallExpr {
+	return &UserCallExpr{false, resolvedLater, name, args, pos}
+}
+
+// PositionError represents an error bound to specific position in source.
+type PositionError struct {
+	// Source line/column position where the error occurred.
+	Position Position
+	// Error message.
+	Message string
+}
+
+// PosErrorf like fmt.Errorf, but with an explicit position.
+func PosErrorf(pos Position, format string, args ...interface{}) error {
+	message := fmt.Sprintf(format, args...)
+	return &PositionError{pos, message}
+}
+
+// Error returns a formatted version of the error, including the line
+// and column numbers.
+func (e *PositionError) Error() string {
+	return fmt.Sprintf("parse error at %d:%d: %s", e.Position.Line, e.Position.Column, e.Message)
+}
diff --git a/internal/ast/walk.go b/internal/ast/walk.go
new file mode 100644
index 0000000..a209ac8
--- /dev/null
+++ b/internal/ast/walk.go
@@ -0,0 +1,180 @@
+package ast
+
+import "fmt"
+
+// A Visitor's Visit method is invoked for each node encountered by Walk.
+// If the result visitor w is not nil, Walk visits each of the children
+// of node with the visitor w, followed by a call of w.Visit(nil).
+type Visitor interface {
+	Visit(node Node) (w Visitor)
+}
+
+// WalkExprList walks a visitor over a list of expression AST nodes
+func WalkExprList(v Visitor, exprs []Expr) {
+	for _, expr := range exprs {
+		Walk(v, expr)
+	}
+}
+
+// WalkStmtList walks a visitor over a list of statement AST nodes
+func WalkStmtList(v Visitor, stmts []Stmt) {
+	for _, stmt := range stmts {
+		Walk(v, stmt)
+	}
+}
+
+// Walk traverses an AST in depth-first order: It starts by calling
+// v.Visit(node); if node is nil, it does nothing. If the visitor w returned by
+// v.Visit(node) is not nil, Walk is invoked recursively with visitor
+// w for each of the non-nil children of node, followed by a call of
+// w.Visit(nil).
+//
+func Walk(v Visitor, node Node) {
+	if node == nil {
+		return
+	}
+	if v = v.Visit(node); v == nil {
+		return
+	}
+
+	// walk children
+	// (the order of the cases matches the order
+	// of the corresponding node types in ast.go)
+	switch n := node.(type) {
+
+	// expressions
+	case *FieldExpr:
+		Walk(v, n.Index)
+
+	case *NamedFieldExpr:
+		Walk(v, n.Field)
+
+	case *UnaryExpr:
+		Walk(v, n.Value)
+
+	case *BinaryExpr:
+		Walk(v, n.Left)
+		Walk(v, n.Right)
+
+	case *ArrayExpr: // leaf
+	case *InExpr:
+		WalkExprList(v, n.Index)
+		Walk(v, n.Array)
+
+	case *CondExpr:
+		Walk(v, n.Cond)
+		Walk(v, n.True)
+		Walk(v, n.False)
+
+	case *NumExpr: // leaf
+	case *StrExpr: // leaf
+	case *RegExpr: // leaf
+	case *VarExpr: // leaf
+	case *IndexExpr:
+		Walk(v, n.Array)
+		WalkExprList(v, n.Index)
+
+	case *AssignExpr:
+		Walk(v, n.Left)
+		Walk(v, n.Right)
+
+	case *AugAssignExpr:
+		Walk(v, n.Left)
+		Walk(v, n.Right)
+
+	case *IncrExpr:
+		Walk(v, n.Expr)
+
+	case *CallExpr:
+		WalkExprList(v, n.Args)
+
+	case *UserCallExpr:
+		WalkExprList(v, n.Args)
+
+	case *MultiExpr:
+		WalkExprList(v, n.Exprs)
+
+	case *GetlineExpr:
+		Walk(v, n.Command)
+		Walk(v, n.Target)
+		Walk(v, n.File)
+
+	// statements
+	case *PrintStmt:
+		WalkExprList(v, n.Args)
+		Walk(v, n.Dest)
+
+	case *PrintfStmt:
+		WalkExprList(v, n.Args)
+		Walk(v, n.Dest)
+
+	case *ExprStmt:
+		Walk(v, n.Expr)
+
+	case *IfStmt:
+		Walk(v, n.Cond)
+		WalkStmtList(v, n.Body)
+		WalkStmtList(v, n.Else)
+
+	case *ForStmt:
+		Walk(v, n.Pre)
+		Walk(v, n.Cond)
+		Walk(v, n.Post)
+		WalkStmtList(v, n.Body)
+
+	case *ForInStmt:
+		Walk(v, n.Var)
+		Walk(v, n.Array)
+		WalkStmtList(v, n.Body)
+
+	case *WhileStmt:
+		Walk(v, n.Cond)
+		WalkStmtList(v, n.Body)
+
+	case *DoWhileStmt:
+		WalkStmtList(v, n.Body)
+		Walk(v, n.Cond)
+
+	case *BreakStmt: // leaf
+	case *ContinueStmt: // leaf
+	case *NextStmt: // leaf
+	case *ExitStmt:
+		Walk(v, n.Status)
+
+	case *DeleteStmt:
+		Walk(v, n.Array)
+		WalkExprList(v, n.Index)
+
+	case *ReturnStmt:
+		Walk(v, n.Value)
+
+	case *BlockStmt:
+		WalkStmtList(v, n.Body)
+
+	case *Program:
+		for _, stmts := range n.Begin {
+			WalkStmtList(v, stmts)
+		}
+		for _, action := range n.Actions {
+			Walk(v, action)
+		}
+		for _, function := range n.Functions {
+			Walk(v, function)
+		}
+		for _, stmts := range n.End {
+			WalkStmtList(v, stmts)
+		}
+
+	case *Action:
+		WalkExprList(v, n.Pattern)
+		WalkStmtList(v, n.Stmts)
+
+	case *Function:
+		WalkStmtList(v, n.Body)
+
+	default:
+		panic(fmt.Sprintf("ast.Walk: unexpected node type %T", n))
+	}
+
+	v.Visit(nil)
+}
diff --git a/internal/compiler/compiler.go b/internal/compiler/compiler.go
index 40b2f89..4687216 100644
--- a/internal/compiler/compiler.go
+++ b/internal/compiler/compiler.go
@@ -54,7 +54,7 @@ func (e *compileError) Error() string {
 }
 
 // Compile compiles an AST (parsed program) into virtual machine instructions.
-func Compile(prog *ast.Program) (compiledProg *Program, err error) {
+func Compile(prog *ast.ResolvedProgram) (compiledProg *Program, err error) {
 	defer func() {
 		// The compiler uses panic with a *compileError to signal compile
 		// errors internally, and they're caught here. This avoids the
diff --git a/parser/resolve.go b/internal/resolver/resolve.go
similarity index 51%
rename from parser/resolve.go
rename to internal/resolver/resolve.go
index a2ed08d..155bd53 100644
--- a/parser/resolve.go
+++ b/internal/resolver/resolve.go
@@ -1,9 +1,9 @@
 // Resolve function calls and variable types
-
-package parser
+package resolver
 
 import (
 	"fmt"
+	"io"
 	"reflect"
 	"sort"
 
@@ -11,6 +11,96 @@ import (
 	. "github.com/benhoyt/goawk/lexer"
 )
 
+type resolver struct {
+	// Resolving state
+	funcName string // function name if parsing a func, else ""
+
+	// Variable tracking and resolving
+	locals    map[string]bool                // current function's locals (for determining scope)
+	varTypes  map[string]map[string]typeInfo // map of func name to var name to type
+	varRefs   []varRef                       // all variable references (usually scalars)
+	arrayRefs []arrayRef                     // all array references
+
+	// Function tracking
+	functions   map[string]int // map of function name to index
+	userCalls   []userCall     // record calls so we can resolve them later
+	nativeFuncs map[string]interface{}
+
+	// Configuration and debugging
+	debugTypes  bool      // show variable types for debugging
+	debugWriter io.Writer // where the debug output goes
+}
+
+type Config struct {
+	// Enable printing of type information
+	DebugTypes bool
+
+	// io.Writer to print type information on (for example, os.Stderr)
+	DebugWriter io.Writer
+
+	// Map of named Go functions to allow calling from AWK. See docs
+	// on interp.Config.Funcs for details.
+	Funcs map[string]interface{}
+}
+
+func Resolve(prog *ast.Program, config *Config) *ast.ResolvedProgram {
+	r := newResolver(config)
+
+	resolvedProg := &ast.ResolvedProgram{Program: *prog}
+
+	ast.Walk(r, prog)
+
+	r.resolveUserCalls(prog)
+	r.resolveVars(resolvedProg)
+
+	return resolvedProg
+}
+
+func (r *resolver) Visit(node ast.Node) ast.Visitor {
+	switch n := node.(type) {
+
+	case *ast.Function:
+		function := n
+		name := function.Name
+		if _, ok := r.functions[name]; ok {
+			panic(ast.PosErrorf(function.Pos, "function %q already defined", name))
+		}
+		r.functions[name] = len(r.functions)
+		r.locals = make(map[string]bool, 7)
+		for _, param := range function.Params {
+			r.locals[param] = true
+		}
+		r.funcName = name
+		r.varTypes[name] = make(map[string]typeInfo)
+
+		ast.WalkStmtList(r, function.Body)
+
+		r.funcName = ""
+		r.locals = nil
+
+	case *ast.VarExpr:
+		r.recordVarRef(n)
+
+	case *ast.ArrayExpr:
+		r.recordArrayRef(n)
+
+	case *ast.UserCallExpr:
+		name := n.Name
+		if r.locals[name] {
+			panic(ast.PosErrorf(n.Pos, "can't call local variable %q as function", name))
+		}
+		for i, arg := range n.Args {
+			ast.Walk(r, arg)
+			r.processUserCallArg(name, arg, i)
+		}
+		r.userCalls = append(r.userCalls, userCall{n, n.Pos, r.funcName})
+	default:
+		return r
+	}
+
+	return nil
+}
+
 type varType int
 
 const (
@@ -60,41 +150,30 @@ type varRef struct {
 	funcName string
 	ref      *ast.VarExpr
 	isArg    bool
-	pos      Position
 }
 
 // A single array reference
 type arrayRef struct {
 	funcName string
 	ref      *ast.ArrayExpr
-	pos      Position
-}
-
-// Initialize the resolver
-func (p *parser) initResolve() {
-	p.varTypes = make(map[string]map[string]typeInfo)
-	p.varTypes[""] = make(map[string]typeInfo) // globals
-	p.functions = make(map[string]int)
-	p.arrayRef("ARGV", Position{1, 1})    // interpreter relies on ARGV being present
-	p.arrayRef("ENVIRON", Position{1, 1}) // and other built-in arrays
-	p.arrayRef("FIELDS", Position{1, 1})
-	p.multiExprs = make(map[*ast.MultiExpr]Position, 3)
-}
-
-// Signal the start of a function
-func (p *parser) startFunction(name string, params []string) {
-	p.funcName = name
-	p.varTypes[name] = make(map[string]typeInfo)
-}
-
-// Signal the end of a function
-func (p *parser) stopFunction() {
-	p.funcName = ""
 }
 
-// Add function by name with given index
-func (p *parser) addFunction(name string, index int) {
-	p.functions[name] = index
+// Constructs the resolver
+func newResolver(config *Config) *resolver {
+	r := &resolver{}
+	if config != nil {
+		r.nativeFuncs = config.Funcs
+		r.debugTypes = config.DebugTypes
+		r.debugWriter = config.DebugWriter
+	}
+	r.varTypes = make(map[string]map[string]typeInfo)
+	r.varTypes[""] = make(map[string]typeInfo) // globals
+	r.functions = make(map[string]int)
+	initialPos := Position{1, 1}
+	r.recordArrayRef(ast.ArrayRef("ARGV", initialPos))    // interpreter relies on ARGV being present
+	r.recordArrayRef(ast.ArrayRef("ENVIRON", initialPos)) // and other built-in arrays
+	r.recordArrayRef(ast.ArrayRef("FIELDS", initialPos))
+	return r
 }
 
 // Records a call to a user function (for resolving indexes later)
@@ -104,18 +183,13 @@ type userCall struct {
 	inFunc string
 }
 
-// Record a user call site
-func (p *parser) recordUserCall(call *ast.UserCallExpr, pos Position) {
-	p.userCalls = append(p.userCalls, userCall{call, pos, p.funcName})
-}
-
 // After parsing, resolve all user calls to their indexes. Also
 // ensures functions called have actually been defined, and that
 // they're not being called with too many arguments.
-func (p *parser) resolveUserCalls(prog *Program) {
+func (r *resolver) resolveUserCalls(prog *ast.Program) {
 	// Number the native funcs (order by name to get consistent order)
-	nativeNames := make([]string, 0, len(p.nativeFuncs))
-	for name := range p.nativeFuncs {
+	nativeNames := make([]string, 0, len(r.nativeFuncs))
+	for name := range r.nativeFuncs {
 		nativeNames = append(nativeNames, name)
 	}
 	sort.Strings(nativeNames)
@@ -124,17 +198,17 @@ func (p *parser) resolveUserCalls(prog *Program) {
 		nativeIndexes[name] = i
 	}
 
-	for _, c := range p.userCalls {
+	for _, c := range r.userCalls {
 		// AWK-defined functions take precedence over native Go funcs
-		index, ok := p.functions[c.call.Name]
+		index, ok := r.functions[c.call.Name]
 		if !ok {
-			f, haveNative := p.nativeFuncs[c.call.Name]
+			f, haveNative := r.nativeFuncs[c.call.Name]
 			if !haveNative {
-				panic(p.posErrorf(c.pos, "undefined function %q", c.call.Name))
+				panic(ast.PosErrorf(c.pos, "undefined function %q", c.call.Name))
 			}
 			typ := reflect.TypeOf(f)
 			if !typ.IsVariadic() && len(c.call.Args) > typ.NumIn() {
-				panic(p.posErrorf(c.pos, "%q called with more arguments than declared", c.call.Name))
+				panic(ast.PosErrorf(c.pos, "%q called with more arguments than declared", c.call.Name))
 			}
 			c.call.Native = true
 			c.call.Index = nativeIndexes[c.call.Name]
@@ -142,7 +216,7 @@ func (p *parser) resolveUserCalls(prog *Program) {
 		}
 		function := prog.Functions[index]
 		if len(c.call.Args) > len(function.Params) {
-			panic(p.posErrorf(c.pos, "%q called with more arguments than declared", c.call.Name))
+			panic(ast.PosErrorf(c.pos, "%q called with more arguments than declared", c.call.Name))
 		}
 		c.call.Index = index
 	}
@@ -150,27 +224,27 @@ func (p *parser) resolveUserCalls(prog *Program) {
 
 // For arguments that are variable references, we don't know the
 // type based on context, so mark the types for these as unknown.
-func (p *parser) processUserCallArg(funcName string, arg ast.Expr, index int) {
+func (r *resolver) processUserCallArg(funcName string, arg ast.Expr, index int) {
 	if varExpr, ok := arg.(*ast.VarExpr); ok {
-		scope, varFuncName := p.getScope(varExpr.Name)
-		ref := p.varTypes[varFuncName][varExpr.Name].ref
+		scope, varFuncName := r.getScope(varExpr.Name)
+		ref := r.varTypes[varFuncName][varExpr.Name].ref
 		if ref == varExpr {
 			// Only applies if this is the first reference to this
 			// variable (otherwise we know the type already)
-			p.varTypes[varFuncName][varExpr.Name] = typeInfo{typeUnknown, ref, scope, 0, funcName, index}
+			r.varTypes[varFuncName][varExpr.Name] = typeInfo{typeUnknown, ref, scope, 0, funcName, index}
 		}
 		// Mark the last related varRef (the most recent one) as a
 		// call argument for later error handling
-		p.varRefs[len(p.varRefs)-1].isArg = true
+		r.varRefs[len(r.varRefs)-1].isArg = true
 	}
 }
 
 // Determine scope of given variable reference (and funcName if it's
 // a local, otherwise empty string)
-func (p *parser) getScope(name string) (ast.VarScope, string) {
+func (r *resolver) getScope(name string) (ast.VarScope, string) {
 	switch {
-	case p.locals[name]:
-		return ast.ScopeLocal, p.funcName
+	case r.locals[name]:
+		return ast.ScopeLocal, r.funcName
 	case ast.SpecialVarIndex(name) > 0:
 		return ast.ScopeSpecial, ""
 	default:
@@ -180,69 +254,69 @@ func (p *parser) getScope(name string) (ast.VarScope, string) {
 
 // Record a variable (scalar) reference and return the *VarExpr (but
 // VarExpr.Index won't be set till later)
-func (p *parser) varRef(name string, pos Position) *ast.VarExpr {
-	scope, funcName := p.getScope(name)
-	expr := &ast.VarExpr{scope, 0, name}
-	p.varRefs = append(p.varRefs, varRef{funcName, expr, false, pos})
-	info := p.varTypes[funcName][name]
+func (r *resolver) recordVarRef(expr *ast.VarExpr) {
+	name := expr.Name
+	scope, funcName := r.getScope(name)
+	expr.Scope = scope
+	r.varRefs = append(r.varRefs, varRef{funcName, expr, false})
+	info := r.varTypes[funcName][name]
 	if info.typ == typeUnknown {
-		p.varTypes[funcName][name] = typeInfo{typeScalar, expr, scope, 0, info.callName, 0}
+		r.varTypes[funcName][name] = typeInfo{typeScalar, expr, scope, 0, info.callName, 0}
 	}
-	return expr
 }
 
 // Record an array reference and return the *ArrayExpr (but
 // ArrayExpr.Index won't be set till later)
-func (p *parser) arrayRef(name string, pos Position) *ast.ArrayExpr {
-	scope, funcName := p.getScope(name)
+func (r *resolver) recordArrayRef(expr *ast.ArrayExpr) {
+	name := expr.Name
+	scope, funcName := r.getScope(name)
 	if scope == ast.ScopeSpecial {
-		panic(p.errorf("can't use scalar %q as array", name))
+		panic(ast.PosErrorf(expr.Pos, "can't use scalar %q as array", name))
 	}
-	expr := &ast.ArrayExpr{scope, 0, name}
-	p.arrayRefs = append(p.arrayRefs, arrayRef{funcName, expr, pos})
-	info := p.varTypes[funcName][name]
+	expr.Scope = scope
+	r.arrayRefs = append(r.arrayRefs, arrayRef{funcName, expr})
+	info := r.varTypes[funcName][name]
 	if info.typ == typeUnknown {
-		p.varTypes[funcName][name] = typeInfo{typeArray, nil, scope, 0, info.callName, 0}
+		r.varTypes[funcName][name] = typeInfo{typeArray, nil, scope, 0, info.callName, 0}
 	}
-	return expr
 }
 
 // Print variable type information (for debugging) on p.debugWriter
-func (p *parser) printVarTypes(prog *Program) {
-	fmt.Fprintf(p.debugWriter, "scalars: %v\n", prog.Scalars)
-	fmt.Fprintf(p.debugWriter, "arrays: %v\n", prog.Arrays)
+func (r *resolver) printVarTypes(prog *ast.ResolvedProgram) {
+	fmt.Fprintf(r.debugWriter, "scalars: %v\n", prog.Scalars)
+	fmt.Fprintf(r.debugWriter, "arrays: %v\n", prog.Arrays)
 	funcNames := []string{}
-	for funcName := range p.varTypes {
+	for funcName := range r.varTypes {
 		funcNames = append(funcNames, funcName)
 	}
 	sort.Strings(funcNames)
 	for _, funcName := range funcNames {
 		if funcName != "" {
-			fmt.Fprintf(p.debugWriter, "function %s\n", funcName)
+			fmt.Fprintf(r.debugWriter, "function %s\n", funcName)
 		} else {
-			fmt.Fprintf(p.debugWriter, "globals\n")
+			fmt.Fprintf(r.debugWriter, "globals\n")
 		}
 		varNames := []string{}
-		for name := range p.varTypes[funcName] {
+		for name := range r.varTypes[funcName] {
 			varNames = append(varNames, name)
 		}
 		sort.Strings(varNames)
 		for _, name := range varNames {
-			info := p.varTypes[funcName][name]
-			fmt.Fprintf(p.debugWriter, "  %s: %s\n", name, info)
+			info := r.varTypes[funcName][name]
+			fmt.Fprintf(r.debugWriter, "  %s: %s\n", name, info)
 		}
 	}
 }
 
 // Resolve unknown variables types and generate variable indexes and
 // name-to-index mappings for interpreter
-func (p *parser) resolveVars(prog *Program) {
+func (r *resolver) resolveVars(prog *ast.ResolvedProgram) {
 	// First go through all unknown types and try to determine the
 	// type from the parameter type in that function definition.
 	// Iterate through functions in topological order, for example
 	// if f() calls g(), process g first, then f.
 	callGraph := make(map[string]map[string]struct{})
-	for _, call := range p.userCalls {
+	for _, call := range r.userCalls {
 		if _, ok := callGraph[call.inFunc]; !ok {
 			callGraph[call.inFunc] = make(map[string]struct{})
 		}
@@ -250,13 +324,13 @@ func (p *parser) resolveVars(prog *Program) {
 	}
 	sortedFuncs := topoSort(callGraph)
 	for _, funcName := range sortedFuncs {
-		infos := p.varTypes[funcName]
+		infos := r.varTypes[funcName]
 		for name, info := range infos {
 			if info.scope == ast.ScopeSpecial || info.typ != typeUnknown {
 				// It's a special var or type is already known
 				continue
 			}
-			funcIndex, ok := p.functions[info.callName]
+			funcIndex, ok := r.functions[info.callName]
 			if !ok {
 				// Function being called is a native function
 				continue
@@ -264,14 +338,14 @@ func (p *parser) resolveVars(prog *Program) {
 			// Determine var type based on type of this parameter
 			// in the called function (if we know that)
 			paramName := prog.Functions[funcIndex].Params[info.argIndex]
-			typ := p.varTypes[info.callName][paramName].typ
+			typ := r.varTypes[info.callName][paramName].typ
 			if typ != typeUnknown {
-				if p.debugTypes {
-					fmt.Fprintf(p.debugWriter, "resolving %s:%s to %s\n",
+				if r.debugTypes {
+					fmt.Fprintf(r.debugWriter, "resolving %s:%s to %s\n",
 						funcName, name, typ)
 				}
 				info.typ = typ
-				p.varTypes[funcName][name] = info
+				r.varTypes[funcName][name] = info
 			}
 		}
 	}
@@ -280,11 +354,12 @@ func (p *parser) resolveVars(prog *Program) {
 	// assign indexes basically randomly)
 	prog.Scalars = make(map[string]int)
 	prog.Arrays = make(map[string]int)
-	for name, info := range p.varTypes[""] {
-		_, isFunc := p.functions[name]
+	for name, info := range r.varTypes[""] {
+		_, isFunc := r.functions[name]
 		if isFunc {
 			// Global var can't also be the name of a function
-			panic(p.errorf("global var %q can't also be a function", name))
+			pos := Position{1, 1} // Ideally it'd be the position of the global var.
+			panic(ast.PosErrorf(pos, "global var %q can't also be a function", name))
 		}
 		var index int
 		if info.scope == ast.ScopeSpecial {
@@ -297,7 +372,7 @@ func (p *parser) resolveVars(prog *Program) {
 			prog.Scalars[name] = index
 		}
 		info.index = index
-		p.varTypes[""][name] = info
+		r.varTypes[""][name] = info
 	}
 
 	// Fill in unknown parameter types that are being called with arrays,
@@ -305,7 +380,7 @@ func (p *parser) resolveVars(prog *Program) {
 	//
 	// BEGIN { arr[0]; f(arr) }
 	// function f(a) { }
-	for _, c := range p.userCalls {
+	for _, c := range r.userCalls {
 		if c.call.Native {
 			continue
 		}
@@ -315,12 +390,12 @@ func (p *parser) resolveVars(prog *Program) {
 			if !ok {
 				continue
 			}
-			funcName := p.getVarFuncName(prog, varExpr.Name, c.inFunc)
-			argType := p.varTypes[funcName][varExpr.Name]
-			paramType := p.varTypes[function.Name][function.Params[i]]
+			funcName := r.getVarFuncName(prog, varExpr.Name, c.inFunc)
+			argType := r.varTypes[funcName][varExpr.Name]
+			paramType := r.varTypes[function.Name][function.Params[i]]
 			if argType.typ == typeArray && paramType.typ == typeUnknown {
 				paramType.typ = argType.typ
-				p.varTypes[function.Name][function.Params[i]] = paramType
+				r.varTypes[function.Name][function.Params[i]] = paramType
 			}
 		}
 	}
@@ -328,13 +403,13 @@ func (p *parser) resolveVars(prog *Program) {
 	// Resolve local variables (assign indexes in order of params).
 	// Also patch up Function.Arrays (tells interpreter which args
 	// are arrays).
-	for funcName, infos := range p.varTypes {
+	for funcName, infos := range r.varTypes {
 		if funcName == "" {
 			continue
 		}
 		scalarIndex := 0
 		arrayIndex := 0
-		functionIndex := p.functions[funcName]
+		functionIndex := r.functions[funcName]
 		function := prog.Functions[functionIndex]
 		arrays := make([]bool, len(function.Params))
 		for i, name := range function.Params {
@@ -352,13 +427,13 @@ func (p *parser) resolveVars(prog *Program) {
 				scalarIndex++
 			}
 			info.index = index
-			p.varTypes[funcName][name] = info
+			r.varTypes[funcName][name] = info
 		}
 		prog.Functions[functionIndex].Arrays = arrays
 	}
 
 	// Check that variables passed to functions are the correct type
-	for _, c := range p.userCalls {
+	for _, c := range r.userCalls {
 		// Check native function calls
 		if c.call.Native {
 			for _, arg := range c.call.Args {
@@ -367,10 +442,10 @@ func (p *parser) resolveVars(prog *Program) {
 					// Non-variable expression, must be scalar
 					continue
 				}
-				funcName := p.getVarFuncName(prog, varExpr.Name, c.inFunc)
-				info := p.varTypes[funcName][varExpr.Name]
+				funcName := r.getVarFuncName(prog, varExpr.Name, c.inFunc)
+				info := r.varTypes[funcName][varExpr.Name]
 				if info.typ == typeArray {
-					panic(p.posErrorf(c.pos, "can't pass array %q to native function", varExpr.Name))
+					panic(ast.PosErrorf(c.pos, "can't pass array %q to native function", varExpr.Name))
 				}
 			}
 			continue
@@ -382,38 +457,38 @@ func (p *parser) resolveVars(prog *Program) {
 			varExpr, ok := arg.(*ast.VarExpr)
 			if !ok {
 				if function.Arrays[i] {
-					panic(p.posErrorf(c.pos, "can't pass scalar %s as array param", arg))
+					panic(ast.PosErrorf(c.pos, "can't pass scalar %s as array param", arg))
 				}
 				continue
 			}
-			funcName := p.getVarFuncName(prog, varExpr.Name, c.inFunc)
-			info := p.varTypes[funcName][varExpr.Name]
+			funcName := r.getVarFuncName(prog, varExpr.Name, c.inFunc)
+			info := r.varTypes[funcName][varExpr.Name]
 			if info.typ == typeArray && !function.Arrays[i] {
-				panic(p.posErrorf(c.pos, "can't pass array %q as scalar param", varExpr.Name))
+				panic(ast.PosErrorf(c.pos, "can't pass array %q as scalar param", varExpr.Name))
 			}
 			if info.typ != typeArray && function.Arrays[i] {
-				panic(p.posErrorf(c.pos, "can't pass scalar %q as array param", varExpr.Name))
+				panic(ast.PosErrorf(c.pos, "can't pass scalar %q as array param", varExpr.Name))
 			}
 		}
 	}
 
-	if p.debugTypes {
-		p.printVarTypes(prog)
+	if r.debugTypes {
+		r.printVarTypes(prog)
 	}
 
 	// Patch up variable indexes (interpreter uses an index instead
 	// of name for more efficient lookups)
-	for _, varRef := range p.varRefs {
-		info := p.varTypes[varRef.funcName][varRef.ref.Name]
+	for _, varRef := range r.varRefs {
+		info := r.varTypes[varRef.funcName][varRef.ref.Name]
 		if info.typ == typeArray && !varRef.isArg {
-			panic(p.posErrorf(varRef.pos, "can't use array %q as scalar", varRef.ref.Name))
+			panic(ast.PosErrorf(varRef.ref.Pos, "can't use array %q as scalar", varRef.ref.Name))
 		}
 		varRef.ref.Index = info.index
 	}
-	for _, arrayRef := range p.arrayRefs {
-		info := p.varTypes[arrayRef.funcName][arrayRef.ref.Name]
+	for _, arrayRef := range r.arrayRefs {
+		info := r.varTypes[arrayRef.funcName][arrayRef.ref.Name]
 		if info.typ == typeScalar {
-			panic(p.posErrorf(arrayRef.pos, "can't use scalar %q as array", arrayRef.ref.Name))
+			panic(ast.PosErrorf(arrayRef.ref.Pos, "can't use scalar %q as array", arrayRef.ref.Name))
 		}
 		arrayRef.ref.Index = info.index
 	}
@@ -421,42 +496,14 @@ func (p *parser) resolveVars(prog *Program) {
 
 // If name refers to a local (in function inFunc), return that
 // function's name, otherwise return "" (meaning global).
-func (p *parser) getVarFuncName(prog *Program, name, inFunc string) string {
+func (r *resolver) getVarFuncName(prog *ast.ResolvedProgram, name, inFunc string) string {
 	if inFunc == "" {
 		return ""
 	}
-	for _, param := range prog.Functions[p.functions[inFunc]].Params {
+	for _, param := range prog.Functions[r.functions[inFunc]].Params {
 		if name == param {
 			return inFunc
 		}
 	}
 	return ""
 }
-
-// Record a "multi expression" (comma-separated pseudo-expression
-// used to allow commas around print/printf arguments).
-func (p *parser) multiExpr(exprs []ast.Expr, pos Position) ast.Expr {
-	expr := &ast.MultiExpr{exprs}
-	p.multiExprs[expr] = pos
-	return expr
-}
-
-// Mark the multi expression as used (by a print/printf statement).
-func (p *parser) useMultiExpr(expr *ast.MultiExpr) {
-	delete(p.multiExprs, expr)
-}
-
-// Check that there are no unused multi expressions (syntax error).
-func (p *parser) checkMultiExprs() {
-	if len(p.multiExprs) == 0 {
-		return
-	}
-	// Show error on first comma-separated expression
-	min := Position{1000000000, 1000000000}
-	for _, pos := range p.multiExprs {
-		if pos.Line < min.Line || pos.Line == min.Line && pos.Column < min.Column {
-			min = pos
-		}
-	}
-	panic(p.posErrorf(min, "unexpected comma-separated expression"))
-}
diff --git a/parser/toposort.go b/internal/resolver/toposort.go
similarity index 98%
rename from parser/toposort.go
rename to internal/resolver/toposort.go
index 90b71fa..745b012 100644
--- a/parser/toposort.go
+++ b/internal/resolver/toposort.go
@@ -1,6 +1,6 @@
 // Topological sorting
 
-package parser
+package resolver
 
 /*
 This algorithm is taken from:
diff --git a/parser/parser.go b/parser/parser.go
index b9fe681..1a2475d 100644
--- a/parser/parser.go
+++ b/parser/parser.go
@@ -13,6 +13,7 @@ import (
 
 	"github.com/benhoyt/goawk/internal/ast"
 	"github.com/benhoyt/goawk/internal/compiler"
+	"github.com/benhoyt/goawk/internal/resolver"
 	. "github.com/benhoyt/goawk/lexer"
 )
 
@@ -45,18 +46,32 @@ type ParserConfig struct {
 	Funcs map[string]interface{}
 }
 
+func (c *ParserConfig) toResolverConfig() *resolver.Config {
+	if c == nil {
+		return nil
+	}
+	return &resolver.Config{
+		DebugTypes:  c.DebugTypes,
+		DebugWriter: c.DebugWriter,
+		Funcs:       c.Funcs,
+	}
+}
+
 // ParseProgram parses an entire AWK program, returning the *Program
 // abstract syntax tree or a *ParseError on error. "config" describes
 // the parser configuration (and is allowed to be nil).
 func ParseProgram(src []byte, config *ParserConfig) (prog *Program, err error) {
 	defer func() {
-		// The parser uses panic with a *ParseError to signal parsing
-		// errors internally, and they're caught here. This
-		// significantly simplifies the recursive descent calls as
-		// we don't have to check errors everywhere.
+		// The parser and resolver use panic with an *ast.PositionError to signal parsing
+		// errors internally, and they're caught here. This significantly simplifies
+		// the recursive descent calls as we don't have to check errors everywhere.
 		if r := recover(); r != nil {
-			// Convert to ParseError or re-panic
-			err = r.(*ParseError)
+			// Convert to PositionError or re-panic
+			posError := *r.(*ast.PositionError)
+			err = &ParseError{
+				Position: posError.Position,
+				Message:  posError.Message,
+			}
 		}
 	}()
 	lexer := NewLexer(src)
@@ -64,16 +79,22 @@ func ParseProgram(src []byte, config *ParserConfig) (prog *Program, err error) {
 	if config != nil {
 		p.debugTypes = config.DebugTypes
 		p.debugWriter = config.DebugWriter
-		p.nativeFuncs = config.Funcs
 	}
-	p.initResolve()
+	p.multiExprs = make(map[*ast.MultiExpr]Position, 3)
+
 	p.next() // initialize p.tok
 
 	// Parse into abstract syntax tree
-	prog = p.program()
+	astProg := p.program()
+
+	prog = &Program{}
+
+	// Resolve step
+	prog.ResolvedProgram = *resolver.Resolve(astProg, config.toResolverConfig())
 
 	// Compile to virtual machine code
-	prog.Compiled, err = compiler.Compile(prog.toAST())
+	prog.Compiled, err = compiler.Compile(&prog.ResolvedProgram)
+
 	return prog, err
 }
 
@@ -83,19 +104,14 @@ type Program struct {
 	// but are exported for the interpreter (Program itself needs to
 	// be exported in package "parser", otherwise these could live in
 	// "internal/ast".)
-	Begin     []ast.Stmts
-	Actions   []ast.Action
-	End       []ast.Stmts
-	Functions []ast.Function
-	Scalars   map[string]int
-	Arrays    map[string]int
-	Compiled  *compiler.Program
+	ast.ResolvedProgram
+	Compiled *compiler.Program
 }
 
 // String returns an indented, pretty-printed version of the parsed
 // program.
 func (p *Program) String() string {
-	return p.toAST().String()
+	return p.ResolvedProgram.Program.String()
 }
 
 // Disassemble writes a human-readable form of the program's virtual machine
@@ -104,18 +120,6 @@ func (p *Program) Disassemble(writer io.Writer) error {
 	return p.Compiled.Disassemble(writer)
 }
 
-// toAST converts the *Program to an *ast.Program.
-func (p *Program) toAST() *ast.Program {
-	return &ast.Program{
-		Begin:     p.Begin,
-		Actions:   p.Actions,
-		End:       p.End,
-		Functions: p.Functions,
-		Scalars:   p.Scalars,
-		Arrays:    p.Arrays,
-	}
-}
-
 // Parser state
 type parser struct {
 	// Lexer instance and current token values
@@ -131,16 +135,7 @@ type parser struct {
 	loopDepth int    // current loop depth (0 if not in any loops)
 
 	// Variable tracking and resolving
-	locals     map[string]bool                // current function's locals (for determining scope)
-	varTypes   map[string]map[string]typeInfo // map of func name to var name to type
-	varRefs    []varRef                       // all variable references (usually scalars)
-	arrayRefs  []arrayRef                     // all array references
-	multiExprs map[*ast.MultiExpr]Position    // tracks comma-separated expressions
-
-	// Function tracking
-	functions   map[string]int // map of function name to index
-	userCalls   []userCall     // record calls so we can resolve them later
-	nativeFuncs map[string]interface{}
+	multiExprs map[*ast.MultiExpr]Position // tracks comma-separated expressions
 
 	// Configuration and debugging
 	debugTypes  bool      // show variable types for debugging
@@ -148,8 +143,8 @@ type parser struct {
 }
 
 // Parse an entire AWK program.
-func (p *parser) program() *Program {
-	prog := &Program{}
+func (p *parser) program() *ast.Program {
+	prog := &ast.Program{}
 
 	// Terminator "(SEMICOLON|NEWLINE) NEWLINE*" is required after each item
 	// with two exceptions where it is optional:
@@ -182,7 +177,6 @@ func (p *parser) program() *Program {
 			prog.End = append(prog.End, p.stmtsBrace())
 		case FUNCTION:
 			function := p.function()
-			p.addFunction(function.Name, len(prog.Functions))
 			prog.Functions = append(prog.Functions, function)
 		default:
 			p.inAction = true
@@ -196,7 +190,7 @@ func (p *parser) program() *Program {
 				pattern = append(pattern, p.expr())
 			}
 			// Or an empty action (equivalent to { print $0 })
-			action := ast.Action{pattern, nil}
+			action := &ast.Action{pattern, nil}
 			if p.tok == LBRACE {
 				action.Stmts = p.stmtsBrace()
 			} else {
@@ -207,8 +201,6 @@ func (p *parser) program() *Program {
 		}
 	}
 
-	p.resolveUserCalls(prog)
-	p.resolveVars(prog)
 	p.checkMultiExprs()
 
 	return prog
@@ -275,7 +267,7 @@ func (p *parser) simpleStmt() ast.Stmt {
 		}
 	case DELETE:
 		p.next()
-		ref := p.arrayRef(p.val, p.pos)
+		ref := ast.ArrayRef(p.val, p.pos)
 		p.expect(NAME)
 		var index []ast.Expr
 		if p.tok == LBRACKET {
@@ -451,7 +443,7 @@ func (p *parser) loopStmts() ast.Stmts {
 // Parse a function definition and body. As it goes, this resolves
 // the local variable indexes and tracks which parameters are array
 // parameters.
-func (p *parser) function() ast.Function {
+func (p *parser) function() *ast.Function {
 	if p.funcName != "" {
 		// Should never actually get here (FUNCTION token is only
 		// handled at the top level), but just in case.
@@ -459,14 +451,12 @@ func (p *parser) function() ast.Function {
 	}
 	p.next()
 	name := p.val
-	if _, ok := p.functions[name]; ok {
-		panic(p.errorf("function %q already defined", name))
-	}
+	funcNamePos := p.pos
 	p.expect(NAME)
 	p.expect(LPAREN)
 	first := true
 	params := make([]string, 0, 7) // pre-allocate some to reduce allocations
-	p.locals = make(map[string]bool, 7)
+	locals := make(map[string]bool, 7)
 	for p.tok != RPAREN {
 		if !first {
 			p.commaNewlines()
@@ -476,23 +466,24 @@ func (p *parser) function() ast.Function {
 		if param == name {
 			panic(p.errorf("can't use function name as parameter name"))
 		}
-		if p.locals[param] {
+		if locals[param] {
 			panic(p.errorf("duplicate parameter name %q", param))
 		}
 		p.expect(NAME)
 		params = append(params, param)
-		p.locals[param] = true
+		locals[param] = true
 	}
 	p.expect(RPAREN)
 	p.optionalNewlines()
 
 	// Parse the body
-	p.startFunction(name, params)
+	p.funcName = name
+
 	body := p.stmtsBrace()
-	p.stopFunction()
-	p.locals = nil
 
-	return ast.Function{name, params, nil, body}
+	p.funcName = ""
+
+	return &ast.Function{name, params, nil, body, funcNamePos}
 }
 
 // Parse expressions separated by commas: args to print[f] or user
@@ -615,7 +606,7 @@ func (p *parser) _in(higher func() ast.Expr) ast.Expr {
 	expr := higher()
 	for p.tok == IN {
 		p.next()
-		ref := p.arrayRef(p.val, p.pos)
+		ref := ast.ArrayRef(p.val, p.pos)
 		p.expect(NAME)
 		expr = &ast.InExpr{[]ast.Expr{expr}, ref}
 	}
@@ -692,7 +683,7 @@ func (p *parser) preIncr() ast.Expr {
 		exprPos := p.pos
 		expr := p.preIncr()
 		if !ast.IsLValue(expr) {
-			panic(p.posErrorf(exprPos, "expected lvalue after ++ or --"))
+			panic(ast.PosErrorf(exprPos, "expected lvalue after ++ or --"))
 		}
 		return &ast.IncrExpr{expr, op, true}
 	}
@@ -748,17 +739,14 @@ func (p *parser) primary() ast.Expr {
 				panic(p.errorf("expected expression instead of ]"))
 			}
 			p.expect(RBRACKET)
-			return &ast.IndexExpr{p.arrayRef(name, namePos), index}
+			return &ast.IndexExpr{ast.ArrayRef(name, namePos), index}
 		} else if p.tok == LPAREN && !p.lexer.HadSpace() {
-			if p.locals[name] {
-				panic(p.errorf("can't call local variable %q as function", name))
-			}
 			// Grammar requires no space between function name and
 			// left paren for user function calls, hence the funky
 			// lexer.HadSpace() method.
 			return p.userCall(name, namePos)
 		}
-		return p.varRef(name, namePos)
+		return ast.VarRef(name, namePos)
 	case LPAREN:
 		parenPos := p.pos
 		p.next()
@@ -774,7 +762,7 @@ func (p *parser) primary() ast.Expr {
 			p.expect(RPAREN)
 			if p.tok == IN {
 				p.next()
-				ref := p.arrayRef(p.val, p.pos)
+				ref := ast.ArrayRef(p.val, p.pos)
 				p.expect(NAME)
 				return &ast.InExpr{exprs, ref}
 			}
@@ -807,7 +795,7 @@ func (p *parser) primary() ast.Expr {
 			inPos := p.pos
 			in := p.expr()
 			if !ast.IsLValue(in) {
-				panic(p.posErrorf(inPos, "3rd arg to sub/gsub must be lvalue"))
+				panic(ast.PosErrorf(inPos, "3rd arg to sub/gsub must be lvalue"))
 			}
 			args = append(args, in)
 		}
@@ -818,7 +806,7 @@ func (p *parser) primary() ast.Expr {
 		p.expect(LPAREN)
 		str := p.expr()
 		p.commaNewlines()
-		ref := p.arrayRef(p.val, p.pos)
+		ref := ast.ArrayRef(p.val, p.pos)
 		p.expect(NAME)
 		args := []ast.Expr{str, ref}
 		if p.tok == COMMA {
@@ -935,9 +923,9 @@ func (p *parser) optionalLValue() ast.Expr {
 				panic(p.errorf("expected expression instead of ]"))
 			}
 			p.expect(RBRACKET)
-			return &ast.IndexExpr{p.arrayRef(name, namePos), index}
+			return &ast.IndexExpr{ast.ArrayRef(name, namePos), index}
 		}
-		return p.varRef(name, namePos)
+		return ast.VarRef(name, namePos)
 	case DOLLAR:
 		p.next()
 		return &ast.FieldExpr{p.primary()}
@@ -1039,13 +1027,7 @@ func (p *parser) matches(operators ...Token) bool {
 // Format given string and args with Sprintf and return *ParseError
 // with that message and the current position.
 func (p *parser) errorf(format string, args ...interface{}) error {
-	return p.posErrorf(p.pos, format, args...)
-}
-
-// Like errorf, but with an explicit position.
-func (p *parser) posErrorf(pos Position, format string, args ...interface{}) error {
-	message := fmt.Sprintf(format, args...)
-	return &ParseError{pos, message}
+	return ast.PosErrorf(p.pos, format, args...)
 }
 
 // Parse call to a user-defined function (and record call site for
@@ -1059,12 +1041,37 @@ func (p *parser) userCall(name string, pos Position) *ast.UserCallExpr {
 			p.commaNewlines()
 		}
 		arg := p.expr()
-		p.processUserCallArg(name, arg, i)
 		args = append(args, arg)
 		i++
 	}
 	p.expect(RPAREN)
-	call := &ast.UserCallExpr{false, -1, name, args} // index is resolved later
-	p.recordUserCall(call, pos)
-	return call
+	return ast.UserCall(name, args, pos)
+}
+
+// Record a "multi expression" (comma-separated pseudo-expression
+// used to allow commas around print/printf arguments).
+func (p *parser) multiExpr(exprs []ast.Expr, pos Position) ast.Expr {
+	expr := &ast.MultiExpr{exprs}
+	p.multiExprs[expr] = pos
+	return expr
+}
+
+// Mark the multi expression as used (by a print/printf statement).
+func (p *parser) useMultiExpr(expr *ast.MultiExpr) {
+	delete(p.multiExprs, expr)
+}
+
+// Check that there are no unused multi expressions (syntax error).
+func (p *parser) checkMultiExprs() {
+	if len(p.multiExprs) == 0 {
+		return
+	}
+	// Show error on first comma-separated expression
+	min := Position{1000000000, 1000000000}
+	for _, pos := range p.multiExprs {
+		if pos.Line < min.Line || pos.Line == min.Line && pos.Column < min.Column {
+			min = pos
+		}
+	}
+	panic(ast.PosErrorf(min, "unexpected comma-separated expression"))
 }


Test patch:
diff --git a/parser/toposort_test.go b/internal/resolver/toposort_test.go
similarity index 99%
rename from parser/toposort_test.go
rename to internal/resolver/toposort_test.go
index d8d4c4c..cb161aa 100644
--- a/parser/toposort_test.go
+++ b/internal/resolver/toposort_test.go
@@ -1,4 +1,4 @@
-package parser
+package resolver
 
 import (
 	"strconv"
diff --git a/interp/interp_test.go b/interp/interp_test.go
index 13de6ff..b359f8d 100644
--- a/interp/interp_test.go
+++ b/interp/interp_test.go
@@ -718,8 +718,8 @@ function bar(foo) { print "bar", foo }
 BEGIN { foo(5); bar(10) }
 `, "", "", `parse error at 2:14: can't use function name as parameter name`, "function name"},
 	{`function foo() { print foo }  BEGIN { foo() }`,
-		"", "", `parse error at 1:46: global var "foo" can't also be a function`, "function"},
-	{`function f(x) { print x, x(); }  BEGIN { f() }`, "", "", `parse error at 1:27: can't call local variable "x" as function`, "function"},
+		"", "", `parse error at 1:1: global var "foo" can't also be a function`, "function"},
+	{`function f(x) { print x, x(); }  BEGIN { f() }`, "", "", `parse error at 1:26: can't call local variable "x" as function`, "function"},
 
 	// Redirected I/O
 	{`BEGIN { getline x; print x }`, "foo", "foo\n", "", ""},
diff --git a/testdata/gawk/callparam.ok b/testdata/gawk/callparam.ok
index 8b074db..9590f8b 100644
--- a/testdata/gawk/callparam.ok
+++ b/testdata/gawk/callparam.ok
@@ -1,1 +1,1 @@
-parse error at 5:7: can't call local variable "b" as function
\ No newline at end of file
+parse error at 5:6: can't call local variable "b" as function
\ No newline at end of file
diff --git a/testdata/gawk/delfunc.ok b/testdata/gawk/delfunc.ok
index 3f53761..b83f623 100644
--- a/testdata/gawk/delfunc.ok
+++ b/testdata/gawk/delfunc.ok
@@ -1,1 +1,1 @@
-parse error at 8:1: global var "f" can't also be a function
\ No newline at end of file
+parse error at 1:1: global var "f" can't also be a function
\ No newline at end of file
diff --git a/testdata/gawk/fnamedat.ok b/testdata/gawk/fnamedat.ok
index 29ef4c5..8449c0a 100644
--- a/testdata/gawk/fnamedat.ok
+++ b/testdata/gawk/fnamedat.ok
@@ -1,1 +1,1 @@
-parse error at 2:1: global var "foo" can't also be a function
\ No newline at end of file
+parse error at 1:1: global var "foo" can't also be a function
\ No newline at end of file
diff --git a/testdata/gawk/fnarray.ok b/testdata/gawk/fnarray.ok
index 0037a9b..8449c0a 100644
--- a/testdata/gawk/fnarray.ok
+++ b/testdata/gawk/fnarray.ok
@@ -1,1 +1,1 @@
-parse error at 8:1: global var "foo" can't also be a function
\ No newline at end of file
+parse error at 1:1: global var "foo" can't also be a function
\ No newline at end of file
diff --git a/testdata/gawk/fnarray2.ok b/testdata/gawk/fnarray2.ok
index 5e56256..cfee62c 100644
--- a/testdata/gawk/fnarray2.ok
+++ b/testdata/gawk/fnarray2.ok
@@ -1,1 +1,1 @@
-parse error at 6:1: global var "pile" can't also be a function
\ No newline at end of file
+parse error at 1:1: global var "pile" can't also be a function
\ No newline at end of file
diff --git a/testdata/gawk/fnasgnm.ok b/testdata/gawk/fnasgnm.ok
index 2a45aeb..957db6c 100644
--- a/testdata/gawk/fnasgnm.ok
+++ b/testdata/gawk/fnasgnm.ok
@@ -1,1 +1,1 @@
-parse error at 15:1: global var "ShowMe" can't also be a function
\ No newline at end of file
+parse error at 1:1: global var "ShowMe" can't also be a function
\ No newline at end of file
diff --git a/testdata/gawk/gsubasgn.ok b/testdata/gawk/gsubasgn.ok
index 99be4d8..264371f 100644
--- a/testdata/gawk/gsubasgn.ok
+++ b/testdata/gawk/gsubasgn.ok
@@ -1,1 +1,1 @@
-parse error at 6:1: global var "test1" can't also be a function
\ No newline at end of file
+parse error at 1:1: global var "test1" can't also be a function
\ No newline at end of file
diff --git a/testdata/gawk/paramasfunc1.ok b/testdata/gawk/paramasfunc1.ok
index c88f020..3a5a1a4 100644
--- a/testdata/gawk/paramasfunc1.ok
+++ b/testdata/gawk/paramasfunc1.ok
@@ -1,1 +1,1 @@
-parse error at 6:15: can't call local variable "abc" as function
\ No newline at end of file
+parse error at 6:12: can't call local variable "abc" as function
\ No newline at end of file
diff --git a/testdata/gawk/paramasfunc2.ok b/testdata/gawk/paramasfunc2.ok
index 543388c..5efab74 100644
--- a/testdata/gawk/paramasfunc2.ok
+++ b/testdata/gawk/paramasfunc2.ok
@@ -1,1 +1,1 @@
-parse error at 8:15: can't call local variable "abc" as function
\ No newline at end of file
+parse error at 8:12: can't call local variable "abc" as function
\ No newline at end of file