Claude Code transcript - page 1/2

Implement the following plan:

Plan: Add source file tracking to parser error messages (Issue #41)

Context

Parser error messages currently show line numbers relative to the preprocessed (expanded) source. When #INCLUDE pulls in a large library like course.module, errors say "line 4335" even though the user's source file is small. This makes errors very hard to diagnose. We need to map expanded line numbers back to original file:line pairs.

Approach

Build a source map in the preprocessor during expansion, then use it in main.go to translate error line numbers before display. This is the least invasive approach — no changes to lexer, parser, or AST.

Changes

1. preproc/preproc.go — Build source map during expansion

  • Add SourceLoc struct: {File string, Line int}
  • Add sourceMap []SourceLoc field to Preprocessor
  • Add SourceMap() []SourceLoc getter method
  • Pass filename through processSource (new parameter) and ProcessFile
  • ProcessSource passes "<input>" as the filename (no file context)
  • For each output line produced, append a SourceLoc{filename, originalLine} entry
  • For #INCLUDE lines: the recursive ProcessFileprocessSource call populates entries for included content; the current line does NOT get an entry
  • For directives that emit blank lines (#DEFINE, #IF, #ELSE, #ENDIF, #COMMENT, etc.): append {filename, lineNum}
  • For skipped lines (inactive condStack): append {filename, lineNum}
  • For regular source lines: append {filename, lineNum}
  • Source map is 0-indexed (entry 0 = expanded line 1)

2. main.go — Translate error line numbers using source map

  • After parsing, if there are errors, get pp.SourceMap()
  • For each error string matching "line NNN: ...", look up NNN in the source map
  • Replace with "filename:line: ..." format (standard compiler error format)
  • If the file is the top-level input file, can show just "filename:line:" or the full path
  • If lookup fails (out of bounds), fall through to original message

3. preproc/preproc_test.go — Test source map

  • Test that ProcessFile with #INCLUDE produces correct source map entries
  • Test that non-included file maps lines 1:1
  • Test that ProcessSource uses "<input>" as filename

Files to modify

  • preproc/preproc.go — add SourceLoc, sourceMap field, pass filename, build map
  • main.go — translate error messages using source map
  • preproc/preproc_test.go — add source map tests

Verification

go test ./...                    # all tests pass
# Manual test with course module:
./occam2go -I kroc/modules/course/libsrc -D TARGET.BITS.PER.WORD=32 -o test.go kroc/modules/course/examples/test_utils.occ
# Error messages should now show filename:line instead of just line NNN

If you need specific details from before exiting plan mode (like exact code snippets, error messages, or content you generated), read the full transcript at: /home/david/.claude/projects/-home-david-projects-code-associates-occam2go/0d8c6c7b-51c0-4085-94aa-867d3704a672.jsonl

Thinking

Let me start by reading the key files I need to modify to understand the current implementation.

I'll start by reading the files I need to modify in parallel.

Read
{
  "file_path": "/home/david/projects/code-associates/occam2go/preproc/preproc.go"
}
     1→// Package preproc implements a textual preprocessor for occam source files.
     2→// It handles #IF/#ELSE/#ENDIF conditional compilation, #DEFINE symbols,
     3→// #INCLUDE file inclusion, and ignores #COMMENT/#PRAGMA/#USE directives.
     4→// The output is a single expanded string suitable for feeding into the lexer.
     5→package preproc
     6→
     7→import (
     8→	"fmt"
     9→	"os"
    10→	"path/filepath"
    11→	"strings"
    12→)
    13→
    14→// Option configures a Preprocessor.
    15→type Option func(*Preprocessor)
    16→
    17→// WithIncludePaths sets the search paths for #INCLUDE resolution.
    18→func WithIncludePaths(paths []string) Option {
    19→	return func(pp *Preprocessor) {
    20→		pp.includePaths = paths
    21→	}
    22→}
    23→
    24→// WithDefines sets predefined symbols.
    25→func WithDefines(defs map[string]string) Option {
    26→	return func(pp *Preprocessor) {
    27→		for k, v := range defs {
    28→			pp.defines[k] = v
    29→		}
    30→	}
    31→}
    32→
    33→// Preprocessor performs textual preprocessing of occam source.
    34→type Preprocessor struct {
    35→	defines      map[string]string
    36→	includePaths []string
    37→	errors       []string
    38→	processing   map[string]bool // absolute paths currently being processed (circular include detection)
    39→	included     map[string]bool // absolute paths already included (prevent duplicate inclusion)
    40→}
    41→
    42→// New creates a new Preprocessor with the given options.
    43→func New(opts ...Option) *Preprocessor {
    44→	pp := &Preprocessor{
    45→		defines:    map[string]string{},
    46→		processing: map[string]bool{},
    47→		included:   map[string]bool{},
    48→	}
    49→	// Predefined symbols
    50→	pp.defines["TARGET.BITS.PER.WORD"] = "64"
    51→
    52→	for _, opt := range opts {
    53→		opt(pp)
    54→	}
    55→	return pp
    56→}
    57→
    58→// Errors returns any errors accumulated during processing.
    59→func (pp *Preprocessor) Errors() []string {
    60→	return pp.errors
    61→}
    62→
    63→// ProcessFile reads and processes a file, resolving #INCLUDE directives.
    64→func (pp *Preprocessor) ProcessFile(filename string) (string, error) {
    65→	absPath, err := filepath.Abs(filename)
    66→	if err != nil {
    67→		return "", fmt.Errorf("cannot resolve path %q: %w", filename, err)
    68→	}
    69→
    70→	if pp.processing[absPath] {
    71→		return "", fmt.Errorf("circular include detected: %s", filename)
    72→	}
    73→	pp.processing[absPath] = true
    74→	defer delete(pp.processing, absPath)
    75→
    76→	data, err := os.ReadFile(absPath)
    77→	if err != nil {
    78→		return "", fmt.Errorf("cannot read %q: %w", filename, err)
    79→	}
    80→
    81→	return pp.processSource(string(data), filepath.Dir(absPath))
    82→}
    83→
    84→// ProcessSource processes occam source text with no file context.
    85→// #INCLUDE directives will only resolve against includePaths.
    86→func (pp *Preprocessor) ProcessSource(source string) (string, error) {
    87→	return pp.processSource(source, "")
    88→}
    89→
    90→// processSource performs line-by-line preprocessing.
    91→// baseDir is the directory of the current file (for relative #INCLUDE resolution).
    92→func (pp *Preprocessor) processSource(source string, baseDir string) (string, error) {
    93→	lines := strings.Split(source, "\n")
    94→	var out strings.Builder
    95→	var condStack []condState
    96→
    97→	for i, line := range lines {
    98→		if i > 0 {
    99→			out.WriteByte('\n')
   100→		}
   101→
   102→		trimmed := strings.TrimSpace(line)
   103→
   104→		if strings.HasPrefix(trimmed, "#") {
   105→			directive, rest := parseDirectiveLine(trimmed)
   106→
   107→			switch directive {
   108→			case "DEFINE":
   109→				if isActive(condStack) {
   110→					sym := strings.TrimSpace(rest)
   111→					if sym != "" {
   112→						pp.defines[sym] = ""
   113→					}
   114→				}
   115→				out.WriteString("") // blank line preserves line numbers
   116→
   117→			case "IF":
   118→				val := pp.evalExpr(rest)
   119→				condStack = append(condStack, condState{active: val, seenTrue: val})
   120→				out.WriteString("")
   121→
   122→			case "ELSE":
   123→				if len(condStack) == 0 {
   124→					pp.errors = append(pp.errors, fmt.Sprintf("line %d: #ELSE without matching #IF", i+1))
   125→				} else {
   126→					top := &condStack[len(condStack)-1]
   127→					if top.seenTrue {
   128→						top.active = false
   129→					} else {
   130→						top.active = true
   131→						top.seenTrue = true
   132→					}
   133→				}
   134→				out.WriteString("")
   135→
   136→			case "ENDIF":
   137→				if len(condStack) == 0 {
   138→					pp.errors = append(pp.errors, fmt.Sprintf("line %d: #ENDIF without matching #IF", i+1))
   139→				} else {
   140→					condStack = condStack[:len(condStack)-1]
   141→				}
   142→				out.WriteString("")
   143→
   144→			case "INCLUDE":
   145→				if isActive(condStack) {
   146→					included, err := pp.resolveAndInclude(rest, baseDir)
   147→					if err != nil {
   148→						return "", fmt.Errorf("line %d: %w", i+1, err)
   149→					}
   150→					out.WriteString(included)
   151→				} else {
   152→					out.WriteString("")
   153→				}
   154→
   155→			case "COMMENT", "PRAGMA", "USE":
   156→				out.WriteString("") // no-op, blank line
   157→
   158→			default:
   159→				// Unknown directive — pass through if active
   160→				if isActive(condStack) {
   161→					out.WriteString(line)
   162→				} else {
   163→					out.WriteString("")
   164→				}
   165→			}
   166→		} else {
   167→			if isActive(condStack) {
   168→				out.WriteString(line)
   169→			} else {
   170→				out.WriteString("") // blank line preserves line numbers
   171→			}
   172→		}
   173→	}
   174→
   175→	if len(condStack) > 0 {
   176→		pp.errors = append(pp.errors, fmt.Sprintf("unterminated #IF (missing %d #ENDIF)", len(condStack)))
   177→	}
   178→
   179→	return out.String(), nil
   180→}
   181→
   182→// condState tracks one level of #IF/#ELSE nesting.
   183→type condState struct {
   184→	active   bool // currently emitting lines?
   185→	seenTrue bool // has any branch been true?
   186→}
   187→
   188→// isActive returns true if all condition stack levels are active.
   189→func isActive(stack []condState) bool {
   190→	for _, s := range stack {
   191→		if !s.active {
   192→			return false
   193→		}
   194→	}
   195→	return true
   196→}
   197→
   198→// parseDirectiveLine splits "#DIRECTIVE rest" into (directive, rest).
   199→func parseDirectiveLine(trimmed string) (string, string) {
   200→	// trimmed starts with "#"
   201→	s := trimmed[1:] // skip '#'
   202→	s = strings.TrimSpace(s)
   203→
   204→	idx := strings.IndexAny(s, " \t")
   205→	if idx == -1 {
   206→		return strings.ToUpper(s), ""
   207→	}
   208→	return strings.ToUpper(s[:idx]), strings.TrimSpace(s[idx+1:])
   209→}
   210→
   211→// resolveAndInclude resolves an #INCLUDE filename and processes the included file.
   212→func (pp *Preprocessor) resolveAndInclude(rest string, baseDir string) (string, error) {
   213→	filename := stripQuotes(rest)
   214→	if filename == "" {
   215→		return "", fmt.Errorf("#INCLUDE with empty filename")
   216→	}
   217→
   218→	// Try to find the file
   219→	resolved := pp.resolveIncludePath(filename, baseDir)
   220→	if resolved == "" {
   221→		return "", fmt.Errorf("cannot find included file %q", filename)
   222→	}
   223→
   224→	// Skip files that have already been included (prevent duplicate definitions)
   225→	absPath, err := filepath.Abs(resolved)
   226→	if err == nil && pp.included[absPath] {
   227→		return "", nil
   228→	}
   229→	if err == nil {
   230→		pp.included[absPath] = true
   231→	}
   232→
   233→	return pp.ProcessFile(resolved)
   234→}
   235→
   236→// resolveIncludePath searches for a file: first relative to baseDir, then in includePaths.
   237→func (pp *Preprocessor) resolveIncludePath(filename string, baseDir string) string {
   238→	// First: relative to current file's directory
   239→	if baseDir != "" {
   240→		candidate := filepath.Join(baseDir, filename)
   241→		if _, err := os.Stat(candidate); err == nil {
   242→			return candidate
   243→		}
   244→	}
   245→
   246→	// Then: each include path
   247→	for _, dir := range pp.includePaths {
   248→		candidate := filepath.Join(dir, filename)
   249→		if _, err := os.Stat(candidate); err == nil {
   250→			return candidate
   251→		}
   252→	}
   253→
   254→	return ""
   255→}
   256→
   257→// stripQuotes removes surrounding double quotes from a string.
   258→func stripQuotes(s string) string {
   259→	s = strings.TrimSpace(s)
   260→	if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
   261→		return s[1 : len(s)-1]
   262→	}
   263→	return s
   264→}
   265→
   266→// evalExpr evaluates a preprocessor conditional expression.
   267→// Supports: TRUE, FALSE, DEFINED (SYMBOL), NOT (expr), (SYMBOL = value)
   268→func (pp *Preprocessor) evalExpr(expr string) bool {
   269→	expr = strings.TrimSpace(expr)
   270→	if expr == "" {
   271→		return false
   272→	}
   273→
   274→	// TRUE / FALSE
   275→	if expr == "TRUE" {
   276→		return true
   277→	}
   278→	if expr == "FALSE" {
   279→		return false
   280→	}
   281→
   282→	// NOT (expr) or NOT DEFINED (...)
   283→	if strings.HasPrefix(expr, "NOT ") || strings.HasPrefix(expr, "NOT(") {
   284→		inner := strings.TrimPrefix(expr, "NOT")
   285→		inner = strings.TrimSpace(inner)
   286→		return !pp.evalExpr(inner)
   287→	}
   288→
   289→	// DEFINED (SYMBOL)
   290→	if strings.HasPrefix(expr, "DEFINED") {
   291→		inner := strings.TrimPrefix(expr, "DEFINED")
   292→		inner = strings.TrimSpace(inner)
   293→		sym := stripParens(inner)
   294→		_, ok := pp.defines[sym]
   295→		return ok
   296→	}
   297→
   298→	// Parenthesized expression
   299→	if strings.HasPrefix(expr, "(") && strings.HasSuffix(expr, ")") {
   300→		inner := expr[1 : len(expr)-1]
   301→		inner = strings.TrimSpace(inner)
   302→
   303→		// Check for equality: SYMBOL = value
   304→		if eqIdx := strings.Index(inner, "="); eqIdx >= 0 {
   305→			lhs := strings.TrimSpace(inner[:eqIdx])
   306→			rhs := strings.TrimSpace(inner[eqIdx+1:])
   307→			lhsVal, ok := pp.defines[lhs]
   308→			if !ok {
   309→				return false
   310→			}
   311→			return lhsVal == rhs
   312→		}
   313→
   314→		// Otherwise recurse
   315→		return pp.evalExpr(inner)
   316→	}
   317→
   318→	// Bare symbol — treat as DEFINED
   319→	_, ok := pp.defines[expr]
   320→	return ok
   321→}
   322→
   323→// stripParens removes surrounding parentheses and whitespace.
   324→func stripParens(s string) string {
   325→	s = strings.TrimSpace(s)
   326→	if len(s) >= 2 && s[0] == '(' && s[len(s)-1] == ')' {
   327→		return strings.TrimSpace(s[1 : len(s)-1])
   328→	}
   329→	return s
   330→}
   331→
Read
{
  "file_path": "/home/david/projects/code-associates/occam2go/main.go"
}
     1→package main
     2→
     3→import (
     4→	"flag"
     5→	"fmt"
     6→	"os"
     7→	"strings"
     8→
     9→	"github.com/codeassociates/occam2go/codegen"
    10→	"github.com/codeassociates/occam2go/lexer"
    11→	"github.com/codeassociates/occam2go/modgen"
    12→	"github.com/codeassociates/occam2go/parser"
    13→	"github.com/codeassociates/occam2go/preproc"
    14→)
    15→
    16→const version = "0.1.0"
    17→
    18→// multiFlag allows a flag to be specified multiple times (e.g. -I path1 -I path2).
    19→type multiFlag []string
    20→
    21→func (f *multiFlag) String() string { return strings.Join(*f, ", ") }
    22→func (f *multiFlag) Set(value string) error {
    23→	*f = append(*f, value)
    24→	return nil
    25→}
    26→
    27→func main() {
    28→	// Check for subcommand before parsing flags
    29→	if len(os.Args) >= 2 && os.Args[1] == "gen-module" {
    30→		genModuleCmd(os.Args[2:])
    31→		return
    32→	}
    33→
    34→	showVersion := flag.Bool("version", false, "Print version and exit")
    35→	outputFile := flag.String("o", "", "Output file (default: stdout)")
    36→	var includePaths multiFlag
    37→	flag.Var(&includePaths, "I", "Include search path (repeatable)")
    38→	var defines multiFlag
    39→	flag.Var(&defines, "D", "Predefined symbol (repeatable)")
    40→
    41→	flag.Usage = func() {
    42→		fmt.Fprintf(os.Stderr, "occam2go - An Occam to Go transpiler\n\n")
    43→		fmt.Fprintf(os.Stderr, "Usage: %s [options] <input.occ>\n", os.Args[0])
    44→		fmt.Fprintf(os.Stderr, "       %s gen-module [-o output] <SConscript>\n\n", os.Args[0])
    45→		fmt.Fprintf(os.Stderr, "Options:\n")
    46→		flag.PrintDefaults()
    47→	}
    48→
    49→	flag.Parse()
    50→
    51→	if *showVersion {
    52→		fmt.Printf("occam2go version %s\n", version)
    53→		os.Exit(0)
    54→	}
    55→
    56→	args := flag.Args()
    57→	if len(args) < 1 {
    58→		flag.Usage()
    59→		os.Exit(1)
    60→	}
    61→
    62→	inputFile := args[0]
    63→
    64→	// Build defines map
    65→	defs := map[string]string{}
    66→	for _, d := range defines {
    67→		if idx := strings.Index(d, "="); idx >= 0 {
    68→			defs[d[:idx]] = d[idx+1:]
    69→		} else {
    70→			defs[d] = ""
    71→		}
    72→	}
    73→
    74→	// Preprocess
    75→	pp := preproc.New(
    76→		preproc.WithIncludePaths(includePaths),
    77→		preproc.WithDefines(defs),
    78→	)
    79→	expanded, err := pp.ProcessFile(inputFile)
    80→	if err != nil {
    81→		fmt.Fprintf(os.Stderr, "Preprocessor error: %s\n", err)
    82→		os.Exit(1)
    83→	}
    84→	if len(pp.Errors()) > 0 {
    85→		fmt.Fprintf(os.Stderr, "Preprocessor warnings:\n")
    86→		for _, e := range pp.Errors() {
    87→			fmt.Fprintf(os.Stderr, "  %s\n", e)
    88→		}
    89→	}
    90→
    91→	// Lex
    92→	l := lexer.New(expanded)
    93→
    94→	// Parse
    95→	p := parser.New(l)
    96→	program := p.ParseProgram()
    97→
    98→	if len(p.Errors()) > 0 {
    99→		fmt.Fprintf(os.Stderr, "Parse errors:\n")
   100→		for _, err := range p.Errors() {
   101→			fmt.Fprintf(os.Stderr, "  %s\n", err)
   102→		}
   103→		os.Exit(1)
   104→	}
   105→
   106→	// Generate Go code
   107→	gen := codegen.New()
   108→	output := gen.Generate(program)
   109→
   110→	// Write output
   111→	if *outputFile != "" {
   112→		err := os.WriteFile(*outputFile, []byte(output), 0644)
   113→		if err != nil {
   114→			fmt.Fprintf(os.Stderr, "Error writing file: %s\n", err)
   115→			os.Exit(1)
   116→		}
   117→	} else {
   118→		fmt.Print(output)
   119→	}
   120→}
   121→
   122→func genModuleCmd(args []string) {
   123→	fs := flag.NewFlagSet("gen-module", flag.ExitOnError)
   124→	outputFile := fs.String("o", "", "Output file (default: stdout)")
   125→	moduleName := fs.String("name", "", "Module guard name (default: derived from library name)")
   126→	fs.Parse(args)
   127→
   128→	if fs.NArg() < 1 {
   129→		fmt.Fprintf(os.Stderr, "Usage: occam2go gen-module [-o output] [-name GUARD] <SConscript>\n")
   130→		os.Exit(1)
   131→	}
   132→
   133→	sconscriptFile := fs.Arg(0)
   134→	data, err := os.ReadFile(sconscriptFile)
   135→	if err != nil {
   136→		fmt.Fprintf(os.Stderr, "Error reading SConscript: %s\n", err)
   137→		os.Exit(1)
   138→	}
   139→
   140→	libs := modgen.ParseSConscript(string(data))
   141→	if len(libs) == 0 {
   142→		fmt.Fprintf(os.Stderr, "No OccamLibrary found in %s\n", sconscriptFile)
   143→		os.Exit(1)
   144→	}
   145→
   146→	// Use first library by default
   147→	lib := libs[0]
   148→
   149→	// Derive module name from library name if not specified
   150→	guard := *moduleName
   151→	if guard == "" {
   152→		// course.lib → COURSE.MODULE
   153→		name := lib.Name
   154→		if idx := strings.LastIndex(name, "."); idx >= 0 {
   155→			name = name[:idx]
   156→		}
   157→		guard = strings.ToUpper(name) + ".MODULE"
   158→	}
   159→
   160→	output := modgen.GenerateModule(lib, guard)
   161→
   162→	if *outputFile != "" {
   163→		err := os.WriteFile(*outputFile, []byte(output), 0644)
   164→		if err != nil {
   165→			fmt.Fprintf(os.Stderr, "Error writing file: %s\n", err)
   166→			os.Exit(1)
   167→		}
   168→	} else {
   169→		fmt.Print(output)
   170→	}
   171→}
   172→
Read
{
  "file_path": "/home/david/projects/code-associates/occam2go/preproc/preproc_test.go"
}
     1→package preproc
     2→
     3→import (
     4→	"os"
     5→	"path/filepath"
     6→	"strings"
     7→	"testing"
     8→)
     9→
    10→func TestDefineAndIfDefined(t *testing.T) {
    11→	pp := New()
    12→	src := `#DEFINE FOO
    13→#IF DEFINED (FOO)
    14→hello
    15→#ENDIF
    16→`
    17→	out, err := pp.ProcessSource(src)
    18→	if err != nil {
    19→		t.Fatal(err)
    20→	}
    21→	lines := strings.Split(out, "\n")
    22→	if strings.TrimSpace(lines[2]) != "hello" {
    23→		t.Errorf("expected 'hello' on line 3, got %q", lines[2])
    24→	}
    25→}
    26→
    27→func TestIfFalseExcludes(t *testing.T) {
    28→	pp := New()
    29→	src := `#IF FALSE
    30→visible
    31→#ENDIF
    32→`
    33→	out, err := pp.ProcessSource(src)
    34→	if err != nil {
    35→		t.Fatal(err)
    36→	}
    37→	if strings.Contains(out, "visible") {
    38→		t.Error("expected #IF FALSE to exclude content")
    39→	}
    40→}
    41→
    42→func TestIfTrue(t *testing.T) {
    43→	pp := New()
    44→	src := `#IF TRUE
    45→visible
    46→#ENDIF
    47→`
    48→	out, err := pp.ProcessSource(src)
    49→	if err != nil {
    50→		t.Fatal(err)
    51→	}
    52→	if !strings.Contains(out, "visible") {
    53→		t.Error("expected #IF TRUE to include content")
    54→	}
    55→}
    56→
    57→func TestElse(t *testing.T) {
    58→	pp := New()
    59→	src := `#IF FALSE
    60→wrong
    61→#ELSE
    62→right
    63→#ENDIF
    64→`
    65→	out, err := pp.ProcessSource(src)
    66→	if err != nil {
    67→		t.Fatal(err)
    68→	}
    69→	if strings.Contains(out, "wrong") {
    70→		t.Error("should not contain 'wrong'")
    71→	}
    72→	if !strings.Contains(out, "right") {
    73→		t.Error("should contain 'right'")
    74→	}
    75→}
    76→
    77→func TestElseNotTakenWhenIfTrue(t *testing.T) {
    78→	pp := New()
    79→	src := `#IF TRUE
    80→right
    81→#ELSE
    82→wrong
    83→#ENDIF
    84→`
    85→	out, err := pp.ProcessSource(src)
    86→	if err != nil {
    87→		t.Fatal(err)
    88→	}
    89→	if !strings.Contains(out, "right") {
    90→		t.Error("should contain 'right'")
    91→	}
    92→	if strings.Contains(out, "wrong") {
    93→		t.Error("should not contain 'wrong'")
    94→	}
    95→}
    96→
    97→func TestNestedIf(t *testing.T) {
    98→	pp := New()
    99→	src := `#DEFINE A
   100→#IF DEFINED (A)
   101→outer
   102→#IF FALSE
   103→inner-hidden
   104→#ENDIF
   105→outer2
   106→#ENDIF
   107→`
   108→	out, err := pp.ProcessSource(src)
   109→	if err != nil {
   110→		t.Fatal(err)
   111→	}
   112→	if !strings.Contains(out, "outer") {
   113→		t.Error("should contain 'outer'")
   114→	}
   115→	if strings.Contains(out, "inner-hidden") {
   116→		t.Error("should not contain 'inner-hidden'")
   117→	}
   118→	if !strings.Contains(out, "outer2") {
   119→		t.Error("should contain 'outer2'")
   120→	}
   121→}
   122→
   123→func TestNotDefined(t *testing.T) {
   124→	pp := New()
   125→	src := `#IF NOT DEFINED (MISSING)
   126→visible
   127→#ENDIF
   128→`
   129→	out, err := pp.ProcessSource(src)
   130→	if err != nil {
   131→		t.Fatal(err)
   132→	}
   133→	if !strings.Contains(out, "visible") {
   134→		t.Error("NOT DEFINED of missing symbol should be true")
   135→	}
   136→}
   137→
   138→func TestLineCountPreservation(t *testing.T) {
   139→	pp := New()
   140→	src := `line1
   141→#IF FALSE
   142→excluded
   143→#ENDIF
   144→line5
   145→`
   146→	out, err := pp.ProcessSource(src)
   147→	if err != nil {
   148→		t.Fatal(err)
   149→	}
   150→	lines := strings.Split(out, "\n")
   151→	// Original has 6 lines (including trailing empty from final \n)
   152→	srcLines := strings.Split(src, "\n")
   153→	if len(lines) != len(srcLines) {
   154→		t.Errorf("line count mismatch: got %d, want %d", len(lines), len(srcLines))
   155→	}
   156→	if lines[0] != "line1" {
   157→		t.Errorf("line 1: got %q, want %q", lines[0], "line1")
   158→	}
   159→	if lines[4] != "line5" {
   160→		t.Errorf("line 5: got %q, want %q", lines[4], "line5")
   161→	}
   162→}
   163→
   164→func TestCommentPragmaUseIgnored(t *testing.T) {
   165→	pp := New()
   166→	src := `#COMMENT "this is a comment"
   167→#PRAGMA SHARED
   168→#USE "somelib"
   169→hello
   170→`
   171→	out, err := pp.ProcessSource(src)
   172→	if err != nil {
   173→		t.Fatal(err)
   174→	}
   175→	if !strings.Contains(out, "hello") {
   176→		t.Error("should contain 'hello'")
   177→	}
   178→	if strings.Contains(out, "COMMENT") || strings.Contains(out, "PRAGMA") || strings.Contains(out, "USE") {
   179→		t.Error("directives should be replaced with blank lines")
   180→	}
   181→}
   182→
   183→func TestEqualityExpression(t *testing.T) {
   184→	pp := New()
   185→	// TARGET.BITS.PER.WORD is predefined as "64"
   186→	src := `#IF (TARGET.BITS.PER.WORD = 64)
   187→is64
   188→#ENDIF
   189→#IF (TARGET.BITS.PER.WORD = 32)
   190→is32
   191→#ENDIF
   192→`
   193→	out, err := pp.ProcessSource(src)
   194→	if err != nil {
   195→		t.Fatal(err)
   196→	}
   197→	if !strings.Contains(out, "is64") {
   198→		t.Error("should match 64-bit")
   199→	}
   200→	if strings.Contains(out, "is32") {
   201→		t.Error("should not match 32-bit")
   202→	}
   203→}
   204→
   205→func TestIncludeGuardPattern(t *testing.T) {
   206→	pp := New()
   207→	src := `#IF NOT (DEFINED (MY.MODULE))
   208→#DEFINE MY.MODULE
   209→content
   210→#ENDIF
   211→#IF NOT (DEFINED (MY.MODULE))
   212→#DEFINE MY.MODULE
   213→duplicate
   214→#ENDIF
   215→`
   216→	out, err := pp.ProcessSource(src)
   217→	if err != nil {
   218→		t.Fatal(err)
   219→	}
   220→	if !strings.Contains(out, "content") {
   221→		t.Error("first include should have content")
   222→	}
   223→	if strings.Contains(out, "duplicate") {
   224→		t.Error("second include should be guarded")
   225→	}
   226→}
   227→
   228→func TestWithDefinesOption(t *testing.T) {
   229→	pp := New(WithDefines(map[string]string{"MY.FLAG": ""}))
   230→	src := `#IF DEFINED (MY.FLAG)
   231→flagged
   232→#ENDIF
   233→`
   234→	out, err := pp.ProcessSource(src)
   235→	if err != nil {
   236→		t.Fatal(err)
   237→	}
   238→	if !strings.Contains(out, "flagged") {
   239→		t.Error("pre-defined flag should be recognized")
   240→	}
   241→}
   242→
   243→func TestPredefinedTargetBits(t *testing.T) {
   244→	pp := New()
   245→	if _, ok := pp.defines["TARGET.BITS.PER.WORD"]; !ok {
   246→		t.Error("TARGET.BITS.PER.WORD should be predefined")
   247→	}
   248→}
   249→
   250→// --- File-based tests for #INCLUDE ---
   251→
   252→func TestIncludeFile(t *testing.T) {
   253→	tmpDir := t.TempDir()
   254→
   255→	// Create included file
   256→	incContent := "INT x:\nx := 42\n"
   257→	os.WriteFile(filepath.Join(tmpDir, "lib.inc"), []byte(incContent), 0644)
   258→
   259→	// Create main file
   260→	mainContent := `#INCLUDE "lib.inc"
   261→print.int(x)
   262→`
   263→	mainFile := filepath.Join(tmpDir, "main.occ")
   264→	os.WriteFile(mainFile, []byte(mainContent), 0644)
   265→
   266→	pp := New()
   267→	out, err := pp.ProcessFile(mainFile)
   268→	if err != nil {
   269→		t.Fatal(err)
   270→	}
   271→	if !strings.Contains(out, "INT x:") {
   272→		t.Error("should include content from lib.inc")
   273→	}
   274→	if !strings.Contains(out, "print.int(x)") {
   275→		t.Error("should contain main file content")
   276→	}
   277→}
   278→
   279→func TestIncludeWithSearchPath(t *testing.T) {
   280→	tmpDir := t.TempDir()
   281→	libDir := filepath.Join(tmpDir, "libs")
   282→	os.Mkdir(libDir, 0755)
   283→
   284→	// Create included file in lib directory
   285→	os.WriteFile(filepath.Join(libDir, "helper.inc"), []byte("INT helper:\n"), 0644)
   286→
   287→	// Create main file that includes from a different directory
   288→	mainContent := `#INCLUDE "helper.inc"
   289→done
   290→`
   291→	mainFile := filepath.Join(tmpDir, "main.occ")
   292→	os.WriteFile(mainFile, []byte(mainContent), 0644)
   293→
   294→	pp := New(WithIncludePaths([]string{libDir}))
   295→	out, err := pp.ProcessFile(mainFile)
   296→	if err != nil {
   297→		t.Fatal(err)
   298→	}
   299→	if !strings.Contains(out, "INT helper:") {
   300→		t.Error("should find file via include path")
   301→	}
   302→}
   303→
   304→func TestIncludeGuardWithFiles(t *testing.T) {
   305→	tmpDir := t.TempDir()
   306→
   307→	// Create guarded module file
   308→	modContent := `#IF NOT (DEFINED (MY.MODULE))
   309→#DEFINE MY.MODULE
   310→INT shared:
   311→#ENDIF
   312→`
   313→	os.WriteFile(filepath.Join(tmpDir, "my.module"), []byte(modContent), 0644)
   314→
   315→	// Create main file that includes twice
   316→	mainContent := `#INCLUDE "my.module"
   317→#INCLUDE "my.module"
   318→done
   319→`
   320→	mainFile := filepath.Join(tmpDir, "main.occ")
   321→	os.WriteFile(mainFile, []byte(mainContent), 0644)
   322→
   323→	pp := New()
   324→	out, err := pp.ProcessFile(mainFile)
   325→	if err != nil {
   326→		t.Fatal(err)
   327→	}
   328→	// "INT shared:" should appear only once
   329→	count := strings.Count(out, "INT shared:")
   330→	if count != 1 {
   331→		t.Errorf("expected 'INT shared:' once, found %d times", count)
   332→	}
   333→}
   334→
   335→func TestNestedIncludes(t *testing.T) {
   336→	tmpDir := t.TempDir()
   337→
   338→	os.WriteFile(filepath.Join(tmpDir, "inner.inc"), []byte("inner-content\n"), 0644)
   339→	os.WriteFile(filepath.Join(tmpDir, "outer.inc"), []byte("#INCLUDE \"inner.inc\"\nouter-content\n"), 0644)
   340→
   341→	mainContent := `#INCLUDE "outer.inc"
   342→main-content
   343→`
   344→	mainFile := filepath.Join(tmpDir, "main.occ")
   345→	os.WriteFile(mainFile, []byte(mainContent), 0644)
   346→
   347→	pp := New()
   348→	out, err := pp.ProcessFile(mainFile)
   349→	if err != nil {
   350→		t.Fatal(err)
   351→	}
   352→	if !strings.Contains(out, "inner-content") {
   353→		t.Error("should contain nested include content")
   354→	}
   355→	if !strings.Contains(out, "outer-content") {
   356→		t.Error("should contain outer include content")
   357→	}
   358→	if !strings.Contains(out, "main-content") {
   359→		t.Error("should contain main content")
   360→	}
   361→}
   362→
   363→func TestCircularIncludeError(t *testing.T) {
   364→	tmpDir := t.TempDir()
   365→
   366→	os.WriteFile(filepath.Join(tmpDir, "a.inc"), []byte("#INCLUDE \"b.inc\"\n"), 0644)
   367→	os.WriteFile(filepath.Join(tmpDir, "b.inc"), []byte("#INCLUDE \"a.inc\"\n"), 0644)
   368→
   369→	mainFile := filepath.Join(tmpDir, "a.inc")
   370→	pp := New()
   371→	_, err := pp.ProcessFile(mainFile)
   372→	if err == nil {
   373→		t.Error("expected circular include error")
   374→	}
   375→	if !strings.Contains(err.Error(), "circular") {
   376→		t.Errorf("expected 'circular' in error, got: %s", err)
   377→	}
   378→}
   379→
   380→func TestIncludeFileNotFound(t *testing.T) {
   381→	tmpDir := t.TempDir()
   382→
   383→	mainContent := `#INCLUDE "nonexistent.inc"
   384→`
   385→	mainFile := filepath.Join(tmpDir, "main.occ")
   386→	os.WriteFile(mainFile, []byte(mainContent), 0644)
   387→
   388→	pp := New()
   389→	_, err := pp.ProcessFile(mainFile)
   390→	if err == nil {
   391→		t.Error("expected file not found error")
   392→	}
   393→	if !strings.Contains(err.Error(), "cannot find") {
   394→		t.Errorf("expected 'cannot find' in error, got: %s", err)
   395→	}
   396→}
   397→
   398→func TestUnterminatedIf(t *testing.T) {
   399→	pp := New()
   400→	src := `#IF TRUE
   401→hello
   402→`
   403→	_, err := pp.ProcessSource(src)
   404→	if err != nil {
   405→		t.Fatal(err)
   406→	}
   407→	if len(pp.Errors()) == 0 {
   408→		t.Error("expected unterminated #IF error")
   409→	}
   410→}
   411→
   412→func TestElseWithoutIf(t *testing.T) {
   413→	pp := New()
   414→	src := `#ELSE
   415→hello
   416→`
   417→	_, err := pp.ProcessSource(src)
   418→	if err != nil {
   419→		t.Fatal(err)
   420→	}
   421→	if len(pp.Errors()) == 0 {
   422→		t.Error("expected #ELSE without #IF error")
   423→	}
   424→}
   425→
   426→func TestEndifWithoutIf(t *testing.T) {
   427→	pp := New()
   428→	src := `#ENDIF
   429→`
   430→	_, err := pp.ProcessSource(src)
   431→	if err != nil {
   432→		t.Fatal(err)
   433→	}
   434→	if len(pp.Errors()) == 0 {
   435→		t.Error("expected #ENDIF without #IF error")
   436→	}
   437→}
   438→
Thinking

Now I have a clear understanding of the code. Let me implement the plan:

  1. Add SourceLoc struct and source map to preprocessor
  2. Pass filename through processSource and build the map
  3. Update main.go to translate error line numbers
  4. Add tests

Let me think about the implementation carefully.

The key insight is that processSource currently emits blank lines (empty strings) for directives and skipped lines to preserve line numbers. But when #INCLUDE is involved, the included content gets inlined, which breaks the 1:1 line mapping.

The source map needs to track, for each line in the expanded output, which original file and line it came from.

Now I'm tracing through how processSource handles each line: directives emit blanks (or for #INCLUDE, the actual included content), while regular lines emit themselves or blanks. For the source map, I need to append location entries for each output line—most map directly to their source file and line number, but #INCLUDE lines require the included file's own source map entries to be merged in.

Looking more carefully at the code, when an #INCLUDE directive is processed, it writes the entire multi-line content of the included file in place of that single directive line. So if the included file has N lines, those N lines appear in the output at that position, and each one needs a source map entry pointing back to the included file rather than the original source. I see the issue now — when an #INCLUDE directive is encountered, the entire included content gets written directly to the output instead of a blank line, which means the included file's lines replace the directive line in the final output. For the source map to work correctly, I need to track which output lines correspond to which source files, so I'll have processSource accept a filename parameter and build the source map incrementally as directives are processed, with included content's mappings coming from the recursive call.

Adding filename parameter... I need to think through how the source map entries should work when includes are active versus inactive. When an include directive is active, the included content replaces that line in the output, so the source map should track entries from the included file itself rather than the include line. When inactive, the include line becomes a blank line and needs its own source map entry. Let me trace through a concrete example to verify this logic. Continuing through the include processing, when libB is encountered it writes a newline first since it's not the first iteration, then outputs "libB" with its source map pointing to lib.inc line 2. The included content becomes "libA\nlibB", which gets written back into the main output. Then when processing line3, another newline is added before the content, giving us the final output "line1\nlibA\nlibB\nline3" with the corresponding source mappings for each line.

I'm noticing that when the included file ends with a newline, like lib.inc does, splitting by newline creates an empty string as the last element, so I need to trace through how that empty line gets handled in the processing. I'm tracing through how the included file content gets merged into the main file's output. When the include directive is processed, the recursive call adds multiple source map entries for each line of the included file, and then the main file continues with its remaining lines, building up the complete output with proper line-by-line source tracking. I'm verifying that the source map correctly tracks line numbers through the expansion—when the parser reports an error at expanded line 5, it maps back to line 3 of the main file, which checks out. Now I'm thinking through how the #INCLUDE directive handles newlines: the outer loop adds a newline at the start of each iteration, then the included content gets written, but I need to consider whether that content already ends with a newline or not, since there's a subtle interaction there. I'm tracing through how the string splitting and newline handling works—when a file ends with a newline, the split creates an empty string at the end, which gets a newline prefix in the output, preserving the trailing newline. So when included content like "foo\nbar\n" gets substituted into the outer file with a newline prefix, it produces "\nfoo\nbar\n" at that position, and the next iteration continues from there. the total newline count determines how many lines end up in the output. Let me trace through the example: starting with "outer line 1", then the included content adds "inc1\ninc2\n", then another newline before "outer line 3", giving me "outer line 1\ninc1\ninc2\n\nouter line 3" which splits into 5 lines when I count the newline separators.

Now I'm mapping source locations to each of those 5 output lines — the first comes from the outer file at line 1, the next three from the included file at lines 1-3, and the last from the outer file at line 3. But I'm noticing something odd: the included file ends with a newline, which creates an empty string when split, and I'm treating that as line 3 of the file even though the file only actually contains 2 real lines of content.

I think this is just how the split function works with trailing newlines, and while it's not ideal to report errors on that phantom empty line, it's acceptable since the real content lines map correctly. Now I need to implement this by adding a source map field to the struct, threading the filename through the processing function, and recording a location entry for each line.

The tricky part is that include directives don't get their own entry—the recursive call handles mapping those lines—but I need to account for how the output is assembled. Each iteration either produces one line of content or multiple lines from an include, and I need to track which source file each output line came from. Modifying the function signature to pass the filename through to the recursive call, and I need to decide whether to use the original filename as provided or the absolute path—the original filename makes more sense for the source map.

For ProcessSource without file context, I'll use "<input>" as the filename placeholder.

Implementing the updated processSource function... Still writing preprocessor logic... I see there's a subtlety with how resolveAndInclude works—when a file's already been included, it returns an empty string, which means no content gets written but the newline from the outer loop is still there. That creates an empty line in the output that needs a corresponding source map entry. So I need to distinguish between two cases in the #INCLUDE handler: when the included content is empty because the file was already processed versus when it's non-empty and the recursive call already handled the source mapping. Writing the include handler...

Now I need to handle translating parser errors back to their original filenames. The parser outputs errors with line numbers like "line 42: unexpected token FOO", so I'll extract those line numbers, look them up in the source map, and rewrite the errors to show the actual filename and line instead.

I'm writing a function that parses these error strings with a regex, converts the line number to an index, looks it up in the source map, and reconstructs the error message with the proper filename and original line number.

Now I need to decide whether to use regex or simpler string parsing — regex is cleaner, so I'll go with that. For the public API, I'm planning to have ProcessFile pass the actual filename to the internal processSource method, while ProcessSource uses a placeholder like <input> since there's no file involved. I also need to expose a SourceMap() getter so callers can access the mapping.

Before I start implementing, I should verify whether the parser already outputs errors in the "line NNN: ..." format.

Let me check the parser error format to make sure I match it correctly in main.go.

Grep
{
  "pattern": "pp\\.errors = append|p\\.errors = append|fmt\\.Sprintf\\(\"line %d",
  "path": "/home/david/projects/code-associates/occam2go",
  "output_mode": "content"
}
parser/parser.go:95:	p.errors = append(p.errors, fmt.Sprintf("line %d: %s", p.curToken.Line, msg))
preproc/preproc.go:124:					pp.errors = append(pp.errors, fmt.Sprintf("line %d: #ELSE without matching #IF", i+1))
preproc/preproc.go:138:					pp.errors = append(pp.errors, fmt.Sprintf("line %d: #ENDIF without matching #IF", i+1))
preproc/preproc.go:176:		pp.errors = append(pp.errors, fmt.Sprintf("unterminated #IF (missing %d #ENDIF)", len(condStack)))
Thinking

Good, the parser errors follow the format "line %d: %s" where %d is p.curToken.Line. These are 1-based line numbers from the lexer.

Now let me implement all three files. Let me create tasks and work through them.

Let me implement the changes now:

  1. preproc/preproc.go - Add SourceLoc, sourceMap, pass filename through processSource
  2. main.go - Translate error line numbers
  3. preproc/preproc_test.go - Add source map tests

Let me be very careful with the edits.

Good — parser errors use "line %d: ..." format with 1-based line numbers. Now let me implement all three files.

TaskCreate
Add SourceLoc struct, sourceMap field, SourceMap() getter, pass filename through processSource, build map entries for each output line
{
  "subject": "Add SourceLoc and source map to preprocessor",
  "activeForm": "Adding source map to preprocessor"
}
Task #1 created successfully: Add SourceLoc and source map to preprocessor
TaskCreate
After parsing, use source map to translate 'line NNN:' errors to 'filename:line:' format
{
  "subject": "Translate parser error line numbers in main.go",
  "activeForm": "Updating main.go error translation"
}
Task #2 created successfully: Translate parser error line numbers in main.go
TaskCreate
Test source map for ProcessSource (uses &lt;input&gt;), ProcessFile with #INCLUDE, and 1:1 mapping for simple files
{
  "subject": "Add source map tests",
  "activeForm": "Adding source map tests"
}
Task #3 created successfully: Add source map tests
TaskCreate
Run go test ./... to verify all tests pass
{
  "subject": "Run tests and verify",
  "activeForm": "Running tests"
}
Task #4 created successfully: Run tests and verify
TaskUpdate
{
  "taskId": "1",
  "status": "in_progress"
}
Updated task #1 status

First, add the SourceLoc type and sourceMap field to the preprocessor:

✏️ Edit preproc.go
/home/david/projects/code-associates/occam2go/preproc/preproc.go
// Preprocessor performs textual preprocessing of occam source.
type Preprocessor struct {
	defines      map[string]string
	includePaths []string
	errors       []string
	processing   map[string]bool // absolute paths currently being processed (circular include detection)
	included     map[string]bool // absolute paths already included (prevent duplicate inclusion)
}
+
// SourceLoc maps an expanded output line back to its original file and line number.
type SourceLoc struct {
	File string
	Line int
}

// Preprocessor performs textual preprocessing of occam source.
type Preprocessor struct {
	defines      map[string]string
	includePaths []string
	errors       []string
	processing   map[string]bool // absolute paths currently being processed (circular include detection)
	included     map[string]bool // absolute paths already included (prevent duplicate inclusion)
	sourceMap    []SourceLoc     // maps each expanded output line (0-indexed) to original file:line
}
The file /home/david/projects/code-associates/occam2go/preproc/preproc.go has been updated successfully.

Add the SourceMap() getter after the Errors() method:

✏️ Edit preproc.go
/home/david/projects/code-associates/occam2go/preproc/preproc.go
// Errors returns any errors accumulated during processing.
func (pp *Preprocessor) Errors() []string {
	return pp.errors
}
+
// Errors returns any errors accumulated during processing.
func (pp *Preprocessor) Errors() []string {
	return pp.errors
}

// SourceMap returns the source map built during preprocessing.
// Entry i corresponds to expanded output line i+1.
func (pp *Preprocessor) SourceMap() []SourceLoc {
	return pp.sourceMap
}
The file /home/david/projects/code-associates/occam2go/preproc/preproc.go has been updated successfully.

Now update ProcessFile to pass the filename to processSource:

✏️ Edit preproc.go
/home/david/projects/code-associates/occam2go/preproc/preproc.go
	return pp.processSource(string(data), filepath.Dir(absPath))
}
+
	return pp.processSource(string(data), filepath.Dir(absPath), filename)
}
The file /home/david/projects/code-associates/occam2go/preproc/preproc.go has been updated successfully.

Update ProcessSource to pass "<input>":

✏️ Edit preproc.go
/home/david/projects/code-associates/occam2go/preproc/preproc.go
// ProcessSource processes occam source text with no file context.
// #INCLUDE directives will only resolve against includePaths.
func (pp *Preprocessor) ProcessSource(source string) (string, error) {
	return pp.processSource(source, "")
}
+
// ProcessSource processes occam source text with no file context.
// #INCLUDE directives will only resolve against includePaths.
func (pp *Preprocessor) ProcessSource(source string) (string, error) {
	return pp.processSource(source, "", "<input>")
}
The file /home/david/projects/code-associates/occam2go/preproc/preproc.go has been updated successfully.

Now update processSource to accept the filename and build source map entries:

✏️ Edit preproc.go
/home/david/projects/code-associates/occam2go/preproc/preproc.go
// processSource performs line-by-line preprocessing.
// baseDir is the directory of the current file (for relative #INCLUDE resolution).
func (pp *Preprocessor) processSource(source string, baseDir string) (string, error) {
	lines := strings.Split(source, "\n")
	var out strings.Builder
	var condStack []condState

	for i, line := range lines {
		if i > 0 {
			out.WriteByte('\n')
		}

		trimmed := strings.TrimSpace(line)

		if strings.HasPrefix(trimmed, "#") {
			directive, rest := parseDirectiveLine(trimmed)

			switch directive {
			case "DEFINE":
				if isActive(condStack) {
					sym := strings.TrimSpace(rest)
					if sym != "" {
						pp.defines[sym] = ""
					}
				}
				out.WriteString("") // blank line preserves line numbers

			case "IF":
				val := pp.evalExpr(rest)
				condStack = append(condStack, condState{active: val, seenTrue: val})
				out.WriteString("")

			case "ELSE":
				if len(condStack) == 0 {
					pp.errors = append(pp.errors, fmt.Sprintf("line %d: #ELSE without matching #IF", i+1))
				} else {
					top := &condStack[len(condStack)-1]
					if top.seenTrue {
						top.active = false
					} else {
						top.active = true
						top.seenTrue = true
					}
				}
				out.WriteString("")

			case "ENDIF":
				if len(condStack) == 0 {
					pp.errors = append(pp.errors, fmt.Sprintf("line %d: #ENDIF without matching #IF", i+1))
				} else {
					condStack = condStack[:len(condStack)-1]
				}
				out.WriteString("")

			case "INCLUDE":
				if isActive(condStack) {
					included, err := pp.resolveAndInclude(rest, baseDir)
					if err != nil {
						return "", fmt.Errorf("line %d: %w", i+1, err)
					}
					out.WriteString(included)
				} else {
					out.WriteString("")
				}

			case "COMMENT", "PRAGMA", "USE":
				out.WriteString("") // no-op, blank line

			default:
				// Unknown directive — pass through if active
				if isActive(condStack) {
					out.WriteString(line)
				} else {
					out.WriteString("")
				}
			}
		} else {
			if isActive(condStack) {
				out.WriteString(line)
			} else {
				out.WriteString("") // blank line preserves line numbers
			}
		}
	}

	if len(condStack) > 0 {
		pp.errors = append(pp.errors, fmt.Sprintf("unterminated #IF (missing %d #ENDIF)", len(condStack)))
	}

	return out.String(), nil
}
+
// processSource performs line-by-line preprocessing.
// baseDir is the directory of the current file (for relative #INCLUDE resolution).
// filename is used for source map entries.
func (pp *Preprocessor) processSource(source string, baseDir string, filename string) (string, error) {
	lines := strings.Split(source, "\n")
	var out strings.Builder
	var condStack []condState

	for i, line := range lines {
		if i > 0 {
			out.WriteByte('\n')
		}

		trimmed := strings.TrimSpace(line)

		if strings.HasPrefix(trimmed, "#") {
			directive, rest := parseDirectiveLine(trimmed)

			switch directive {
			case "DEFINE":
				if isActive(condStack) {
					sym := strings.TrimSpace(rest)
					if sym != "" {
						pp.defines[sym] = ""
					}
				}
				out.WriteString("") // blank line preserves line numbers
				pp.sourceMap = append(pp.sourceMap, SourceLoc{filename, i + 1})

			case "IF":
				val := pp.evalExpr(rest)
				condStack = append(condStack, condState{active: val, seenTrue: val})
				out.WriteString("")
				pp.sourceMap = append(pp.sourceMap, SourceLoc{filename, i + 1})

			case "ELSE":
				if len(condStack) == 0 {
					pp.errors = append(pp.errors, fmt.Sprintf("line %d: #ELSE without matching #IF", i+1))
				} else {
					top := &condStack[len(condStack)-1]
					if top.seenTrue {
						top.active = false
					} else {
						top.active = true
						top.seenTrue = true
					}
				}
				out.WriteString("")
				pp.sourceMap = append(pp.sourceMap, SourceLoc{filename, i + 1})

			case "ENDIF":
				if len(condStack) == 0 {
					pp.errors = append(pp.errors, fmt.Sprintf("line %d: #ENDIF without matching #IF", i+1))
				} else {
					condStack = condStack[:len(condStack)-1]
				}
				out.WriteString("")
				pp.sourceMap = append(pp.sourceMap, SourceLoc{filename, i + 1})

			case "INCLUDE":
				if isActive(condStack) {
					included, err := pp.resolveAndInclude(rest, baseDir)
					if err != nil {
						return "", fmt.Errorf("line %d: %w", i+1, err)
					}
					out.WriteString(included)
					if included == "" {
						// File already included (dedup) — entry for the blank line
						pp.sourceMap = append(pp.sourceMap, SourceLoc{filename, i + 1})
					}
					// Otherwise, entries were added by the recursive processSource call
				} else {
					out.WriteString("")
					pp.sourceMap = append(pp.sourceMap, SourceLoc{filename, i + 1})
				}

			case "COMMENT", "PRAGMA", "USE":
				out.WriteString("") // no-op, blank line
				pp.sourceMap = append(pp.sourceMap, SourceLoc{filename, i + 1})

			default:
				// Unknown directive — pass through if active
				if isActive(condStack) {
					out.WriteString(line)
				} else {
					out.WriteString("")
				}
				pp.sourceMap = append(pp.sourceMap, SourceLoc{filename, i + 1})
			}
		} else {
			if isActive(condStack) {
				out.WriteString(line)
			} else {
				out.WriteString("") // blank line preserves line numbers
			}
			pp.sourceMap = append(pp.sourceMap, SourceLoc{filename, i + 1})
		}
	}

	if len(condStack) > 0 {
		pp.errors = append(pp.errors, fmt.Sprintf("unterminated #IF (missing %d #ENDIF)", len(condStack)))
	}

	return out.String(), nil
}
The file /home/david/projects/code-associates/occam2go/preproc/preproc.go has been updated successfully.
TaskUpdate
{
  "taskId": "1",
  "status": "completed"
}
Updated task #1 status
TaskUpdate
{
  "taskId": "2",
  "status": "in_progress"
}
Updated task #2 status

Now update main.go to translate parser error line numbers using the source map:

✏️ Edit main.go
/home/david/projects/code-associates/occam2go/main.go
import (
	"flag"
	"fmt"
	"os"
	"strings"

	"github.com/codeassociates/occam2go/codegen"
	"github.com/codeassociates/occam2go/lexer"
	"github.com/codeassociates/occam2go/modgen"
	"github.com/codeassociates/occam2go/parser"
	"github.com/codeassociates/occam2go/preproc"
)
+
import (
	"flag"
	"fmt"
	"os"
	"regexp"
	"strconv"
	"strings"

	"github.com/codeassociates/occam2go/codegen"
	"github.com/codeassociates/occam2go/lexer"
	"github.com/codeassociates/occam2go/modgen"
	"github.com/codeassociates/occam2go/parser"
	"github.com/codeassociates/occam2go/preproc"
)
The file /home/david/projects/code-associates/occam2go/main.go has been updated successfully.

Now update the error printing section to translate line numbers:

✏️ Edit main.go
/home/david/projects/code-associates/occam2go/main.go
	if len(p.Errors()) > 0 {
		fmt.Fprintf(os.Stderr, "Parse errors:\n")
		for _, err := range p.Errors() {
			fmt.Fprintf(os.Stderr, "  %s\n", err)
		}
		os.Exit(1)
	}
+
	if len(p.Errors()) > 0 {
		fmt.Fprintf(os.Stderr, "Parse errors:\n")
		sourceMap := pp.SourceMap()
		for _, err := range p.Errors() {
			fmt.Fprintf(os.Stderr, "  %s\n", translateError(err, sourceMap))
		}
		os.Exit(1)
	}
The file /home/david/projects/code-associates/occam2go/main.go has been updated successfully.

Now add the translateError function before genModuleCmd:

✏️ Edit main.go
/home/david/projects/code-associates/occam2go/main.go
func genModuleCmd(args []string) {
+
var lineErrRe = regexp.MustCompile(`^line (\d+): (.*)`)

// translateError rewrites "line NNN: msg" to "file:line: msg" using the source map.
func translateError(errMsg string, sourceMap []preproc.SourceLoc) string {
	m := lineErrRe.FindStringSubmatch(errMsg)
	if m == nil {
		return errMsg
	}
	lineNum, _ := strconv.Atoi(m[1])
	idx := lineNum - 1 // source map is 0-indexed
	if idx < 0 || idx >= len(sourceMap) {
		return errMsg
	}
	loc := sourceMap[idx]
	return fmt.Sprintf("%s:%d: %s", loc.File, loc.Line, m[2])
}

func genModuleCmd(args []string) {
The file /home/david/projects/code-associates/occam2go/main.go has been updated successfully.
TaskUpdate
{
  "taskId": "2",
  "status": "completed"
}
Updated task #2 status
TaskUpdate
{
  "taskId": "3",
  "status": "in_progress"
}
Updated task #3 status

Now add source map tests:

✏️ Edit preproc_test.go
/home/david/projects/code-associates/occam2go/preproc/preproc_test.go
func TestEndifWithoutIf(t *testing.T) {
	pp := New()
	src := `#ENDIF
`
	_, err := pp.ProcessSource(src)
	if err != nil {
		t.Fatal(err)
	}
	if len(pp.Errors()) == 0 {
		t.Error("expected #ENDIF without #IF error")
	}
}
+
func TestEndifWithoutIf(t *testing.T) {
	pp := New()
	src := `#ENDIF
`
	_, err := pp.ProcessSource(src)
	if err != nil {
		t.Fatal(err)
	}
	if len(pp.Errors()) == 0 {
		t.Error("expected #ENDIF without #IF error")
	}
}

// --- Source map tests ---

func TestSourceMapSimple(t *testing.T) {
	pp := New()
	src := "line1\nline2\nline3"
	out, err := pp.ProcessSource(src)
	if err != nil {
		t.Fatal(err)
	}
	lines := strings.Split(out, "\n")
	sm := pp.SourceMap()
	if len(sm) != len(lines) {
		t.Fatalf("source map length %d != output lines %d", len(sm), len(lines))
	}
	for i, loc := range sm {
		if loc.File != "<input>" {
			t.Errorf("entry %d: file = %q, want %q", i, loc.File, "<input>")
		}
		if loc.Line != i+1 {
			t.Errorf("entry %d: line = %d, want %d", i, loc.Line, i+1)
		}
	}
}

func TestSourceMapWithDirectives(t *testing.T) {
	pp := New()
	src := "#DEFINE FOO\n#IF TRUE\nhello\n#ENDIF\nworld"
	_, err := pp.ProcessSource(src)
	if err != nil {
		t.Fatal(err)
	}
	sm := pp.SourceMap()
	// 5 source lines → 5 source map entries
	if len(sm) != 5 {
		t.Fatalf("source map length = %d, want 5", len(sm))
	}
	// Each entry maps to its original line in <input>
	for i, loc := range sm {
		if loc.File != "<input>" {
			t.Errorf("entry %d: file = %q, want %q", i, loc.File, "<input>")
		}
		if loc.Line != i+1 {
			t.Errorf("entry %d: line = %d, want %d", i, loc.Line, i+1)
		}
	}
}

func TestSourceMapWithInclude(t *testing.T) {
	tmpDir := t.TempDir()

	// Create included file (2 lines + trailing newline = 3 entries after split)
	os.WriteFile(filepath.Join(tmpDir, "inc.occ"), []byte("incA\nincB\n"), 0644)

	// Create main file: line1, #INCLUDE, line3
	mainContent := "line1\n#INCLUDE \"inc.occ\"\nline3\n"
	mainFile := filepath.Join(tmpDir, "main.occ")
	os.WriteFile(mainFile, []byte(mainContent), 0644)

	pp := New()
	out, err := pp.ProcessFile(mainFile)
	if err != nil {
		t.Fatal(err)
	}

	sm := pp.SourceMap()
	outLines := strings.Split(out, "\n")
	if len(sm) != len(outLines) {
		t.Fatalf("source map length %d != output lines %d", len(sm), len(outLines))
	}

	// Entry 0: main.occ line 1
	if sm[0].Line != 1 || !strings.HasSuffix(sm[0].File, "main.occ") {
		t.Errorf("entry 0: got {%s, %d}, want {main.occ, 1}", sm[0].File, sm[0].Line)
	}

	// Entries 1-3: inc.occ lines 1-3
	incFile := filepath.Join(tmpDir, "inc.occ")
	for i := 1; i <= 3; i++ {
		if sm[i].File != incFile {
			t.Errorf("entry %d: file = %q, want %q", i, sm[i].File, incFile)
		}
		if sm[i].Line != i {
			t.Errorf("entry %d: line = %d, want %d", i, sm[i].Line, i)
		}
	}

	// Entry 4: main.occ line 3
	if sm[4].Line != 3 || !strings.HasSuffix(sm[4].File, "main.occ") {
		t.Errorf("entry 4: got {%s, %d}, want {main.occ, 3}", sm[4].File, sm[4].Line)
	}
}
The file /home/david/projects/code-associates/occam2go/preproc/preproc_test.go has been updated successfully.
TaskUpdate
{
  "taskId": "3",
  "status": "completed"
}
Updated task #3 status
TaskUpdate
{
  "taskId": "4",
  "status": "in_progress"
}
Updated task #4 status
$ Bash
Run all tests
go test ./...
?   	github.com/codeassociates/occam2go	[no test files]
?   	github.com/codeassociates/occam2go/ast	[no test files]
ok  	github.com/codeassociates/occam2go/codegen	16.237s
ok  	github.com/codeassociates/occam2go/lexer	0.002s
ok  	github.com/codeassociates/occam2go/modgen	0.002s
ok  	github.com/codeassociates/occam2go/parser	(cached)
ok  	github.com/codeassociates/occam2go/preproc	0.004s

All tests pass. Let me also verify the course module still works:

$ Bash
Build and test course module transpilation
go build -o occam2go . && ./occam2go -I kroc/modules/course/libsrc -D TARGET.BITS.PER.WORD=32 -o /tmp/course_out.go kroc/modules/course/libsrc/course.module && go vet /tmp/course_out.go 2>&1

Course module transpiles and passes go vet cleanly. Let me do a quick manual test to confirm error messages show the new format:

$ Bash
Test error output with source file tracking
tmpdir=$(mktemp -d) && echo 'INT x:
x := 42
INVALID STUFF HERE' > "$tmpdir/test.occ" && /home/david/projects/code-associates/occam2go/occam2go "$tmpdir/test.occ" 2>&1; rm -rf "$tmpdir"
package main

func main() {
	var x int
	_ = x
	x = 42
	INVALID()
	STUFF()
	HERE()
}
Thinking

It didn't produce errors because those were treated as proc calls. Let me try something that would actually cause a parse error.

That parsed successfully (treated as proc calls). Let me try something that actually triggers a parse error:

$ Bash
Test parse error with source file tracking
tmpdir=$(mktemp -d) && echo 'PROC foo()
  INT x:
  x :=
:' > "$tmpdir/test.occ" && /home/david/projects/code-associates/occam2go/occam2go "$tmpdir/test.occ" 2>&1; rm -rf "$tmpdir"
Parse errors:
  /tmp/tmp.3Yw39I683r/test.occ:4: unexpected token in expression: :

The error now shows test.occ:4: instead of line 4:. Let me also test with an include to confirm it maps to the right file:

$ Bash
Test error in included file shows correct source location
tmpdir=$(mktemp -d) && echo 'PROC helper()
  INT x:
  x :=
:' > "$tmpdir/lib.occ" && echo '#INCLUDE "lib.occ"
PROC main()
  helper()
:' > "$tmpdir/main.occ" && /home/david/projects/code-associates/occam2go/occam2go "$tmpdir/main.occ" 2>&1; rm -rf "$tmpdir"
Parse errors:
  /tmp/tmp.L1PeNxIkzM/lib.occ:4: unexpected token in expression: :

The error correctly points to lib.occ:4 even though the error is in an included file.

TaskUpdate
{
  "taskId": "4",
  "status": "completed"
}
Updated task #4 status

All done. Here's a summary of the changes:

preproc/preproc.go — Added source map tracking: - New SourceLoc struct with File and Line fields - New sourceMap []SourceLoc field on Preprocessor - New SourceMap() getter method - processSource now takes a filename parameter and appends a SourceLoc entry for each output line - ProcessFile passes the original filename; ProcessSource passes "<input>" - For #INCLUDE, the recursive call populates entries for the included content; only adds an entry for the #INCLUDE line itself when the file was already included (dedup case)

main.go — Error translation: - New translateError() function that rewrites "line NNN: msg" to "file:line: msg" using the source map - Parser errors are translated before printing

preproc/preproc_test.go — Three new tests: - TestSourceMapSimple — verifies 1:1 mapping for plain source with <input> filename - TestSourceMapWithDirectives — verifies directives still produce correct line mappings - TestSourceMapWithInclude — verifies included file content maps to the included file's name and lines

Thank you. Please create a PR for this feature.

$ Bash
Show working tree status
git status
On branch main
Your branch is up to date with 'origin/main'.

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
	modified:   main.go
	modified:   preproc/preproc.go
	modified:   preproc/preproc_test.go

no changes added to commit (use "git add" and/or "git commit -a")
$ Bash
Show staged and unstaged changes
git diff
diff --git a/main.go b/main.go
index c5ee77e..7644166 100644
--- a/main.go
+++ b/main.go
@@ -4,6 +4,8 @@ import (
 	"flag"
 	"fmt"
 	"os"
+	"regexp"
+	"strconv"
 	"strings"
 
 	"github.com/codeassociates/occam2go/codegen"
@@ -97,8 +99,9 @@ func main() {
 
 	if len(p.Errors()) > 0 {
 		fmt.Fprintf(os.Stderr, "Parse errors:\n")
+		sourceMap := pp.SourceMap()
 		for _, err := range p.Errors() {
-			fmt.Fprintf(os.Stderr, "  %s\n", err)
+			fmt.Fprintf(os.Stderr, "  %s\n", translateError(err, sourceMap))
 		}
 		os.Exit(1)
 	}
@@ -119,6 +122,23 @@ func main() {
 	}
 }
 
+var lineErrRe = regexp.MustCompile(`^line (\d+): (.*)`)
+
+// translateError rewrites "line NNN: msg" to "file:line: msg" using the source map.
+func translateError(errMsg string, sourceMap []preproc.SourceLoc) string {
+	m := lineErrRe.FindStringSubmatch(errMsg)
+	if m == nil {
+		return errMsg
+	}
+	lineNum, _ := strconv.Atoi(m[1])
+	idx := lineNum - 1 // source map is 0-indexed
+	if idx < 0 || idx >= len(sourceMap) {
+		return errMsg
+	}
+	loc := sourceMap[idx]
+	return fmt.Sprintf("%s:%d: %s", loc.File, loc.Line, m[2])
+}
+
 func genModuleCmd(args []string) {
 	fs := flag.NewFlagSet("gen-module", flag.ExitOnError)
 	outputFile := fs.String("o", "", "Output file (default: stdout)")
diff --git a/preproc/preproc.go b/preproc/preproc.go
index b0faea3..5775ef0 100644
--- a/preproc/preproc.go
+++ b/preproc/preproc.go
@@ -30,6 +30,12 @@ func WithDefines(defs map[string]string) Option {
 	}
 }
 
+// SourceLoc maps an expanded output line back to its original file and line number.
+type SourceLoc struct {
+	File string
+	Line int
+}
+
 // Preprocessor performs textual preprocessing of occam source.
 type Preprocessor struct {
 	defines      map[string]string
@@ -37,6 +43,7 @@ type Preprocessor struct {
 	errors       []string
 	processing   map[string]bool // absolute paths currently being processed (circular include detection)
 	included     map[string]bool // absolute paths already included (prevent duplicate inclusion)
+	sourceMap    []SourceLoc     // maps each expanded output line (0-indexed) to original file:line
 }
 
 // New creates a new Preprocessor with the given options.
@@ -60,6 +67,12 @@ func (pp *Preprocessor) Errors() []string {
 	return pp.errors
 }
 
+// SourceMap returns the source map built during preprocessing.
+// Entry i corresponds to expanded output line i+1.
+func (pp *Preprocessor) SourceMap() []SourceLoc {
+	return pp.sourceMap
+}
+
 // ProcessFile reads and processes a file, resolving #INCLUDE directives.
 func (pp *Preprocessor) ProcessFile(filename string) (string, error) {
 	absPath, err := filepath.Abs(filename)
@@ -78,18 +91,19 @@ func (pp *Preprocessor) ProcessFile(filename string) (string, error) {
 		return "", fmt.Errorf("cannot read %q: %w", filename, err)
 	}
 
-	return pp.processSource(string(data), filepath.Dir(absPath))
+	return pp.processSource(string(data), filepath.Dir(absPath), filename)
 }
 
 // ProcessSource processes occam source text with no file context.
 // #INCLUDE directives will only resolve against includePaths.
 func (pp *Preprocessor) ProcessSource(source string) (string, error) {
-	return pp.processSource(source, "")
+	return pp.processSource(source, "", "<input>")
 }
 
 // processSource performs line-by-line preprocessing.
 // baseDir is the directory of the current file (for relative #INCLUDE resolution).
-func (pp *Preprocessor) processSource(source string, baseDir string) (string, error) {
+// filename is used for source map entries.
+func (pp *Preprocessor) processSource(source string, baseDir string, filename string) (string, error) {
 	lines := strings.Split(source, "\n")
 	var out strings.Builder
 	var condStack []condState
@@ -113,11 +127,13 @@ func (pp *Preprocessor) processSource(source string, baseDir string) (string, er
 					}
 				}
 				out.WriteString("") // blank line preserves line numbers
+				pp.sourceMap = append(pp.sourceMap, SourceLoc{filename, i + 1})
 
 			case "IF":
 				val := pp.evalExpr(rest)
 				condStack = append(condStack, condState{active: val, seenTrue: val})
 				out.WriteString("")
+				pp.sourceMap = append(pp.sourceMap, SourceLoc{filename, i + 1})
 
 			case "ELSE":
 				if len(condStack) == 0 {
@@ -132,6 +148,7 @@ func (pp *Preprocessor) processSource(source string, baseDir string) (string, er
 					}
 				}
 				out.WriteString("")
+				pp.sourceMap = append(pp.sourceMap, SourceLoc{filename, i + 1})
 
 			case "ENDIF":
 				if len(condStack) == 0 {
@@ -140,6 +157,7 @@ func (pp *Preprocessor) processSource(source string, baseDir string) (string, er
 					condStack = condStack[:len(condStack)-1]
 				}
 				out.WriteString("")
+				pp.sourceMap = append(pp.sourceMap, SourceLoc{filename, i + 1})
 
 			case "INCLUDE":
 				if isActive(condStack) {
@@ -148,12 +166,19 @@ func (pp *Preprocessor) processSource(source string, baseDir string) (string, er
 						return "", fmt.Errorf("line %d: %w", i+1, err)
 					}
 					out.WriteString(included)
+					if included == "" {
+						// File already included (dedup) — entry for the blank line
+						pp.sourceMap = append(pp.sourceMap, SourceLoc{filename, i + 1})
+					}
+					// Otherwise, entries were added by the recursive processSource call
 				} else {
 					out.WriteString("")
+					pp.sourceMap = append(pp.sourceMap, SourceLoc{filename, i + 1})
 				}
 
 			case "COMMENT", "PRAGMA", "USE":
 				out.WriteString("") // no-op, blank line
+				pp.sourceMap = append(pp.sourceMap, SourceLoc{filename, i + 1})
 
 			default:
 				// Unknown directive — pass through if active
@@ -162,6 +187,7 @@ func (pp *Preprocessor) processSource(source string, baseDir string) (string, er
 				} else {
 					out.WriteString("")
 				}
+				pp.sourceMap = append(pp.sourceMap, SourceLoc{filename, i + 1})
 			}
 		} else {
 			if isActive(condStack) {
@@ -169,6 +195,7 @@ func (pp *Preprocessor) processSource(source string, baseDir string) (string, er
 			} else {
 				out.WriteString("") // blank line preserves line numbers
 			}
+			pp.sourceMap = append(pp.sourceMap, SourceLoc{filename, i + 1})
 		}
 	}
 
diff --git a/preproc/preproc_test.go b/preproc/preproc_test.go
index 9939551..128b7dc 100644
--- a/preproc/preproc_test.go
+++ b/preproc/preproc_test.go
@@ -435,3 +435,95 @@ func TestEndifWithoutIf(t *testing.T) {
 		t.Error("expected #ENDIF without #IF error")
 	}
 }
+
+// --- Source map tests ---
+
+func TestSourceMapSimple(t *testing.T) {
+	pp := New()
+	src := "line1\nline2\nline3"
+	out, err := pp.ProcessSource(src)
+	if err != nil {
+		t.Fatal(err)
+	}
+	lines := strings.Split(out, "\n")
+	sm := pp.SourceMap()
+	if len(sm) != len(lines) {
+		t.Fatalf("source map length %d != output lines %d", len(sm), len(lines))
+	}
+	for i, loc := range sm {
+		if loc.File != "<input>" {
+			t.Errorf("entry %d: file = %q, want %q", i, loc.File, "<input>")
+		}
+		if loc.Line != i+1 {
+			t.Errorf("entry %d: line = %d, want %d", i, loc.Line, i+1)
+		}
+	}
+}
+
+func TestSourceMapWithDirectives(t *testing.T) {
+	pp := New()
+	src := "#DEFINE FOO\n#IF TRUE\nhello\n#ENDIF\nworld"
+	_, err := pp.ProcessSource(src)
+	if err != nil {
+		t.Fatal(err)
+	}
+	sm := pp.SourceMap()
+	// 5 source lines → 5 source map entries
+	if len(sm) != 5 {
+		t.Fatalf("source map length = %d, want 5", len(sm))
+	}
+	// Each entry maps to its original line in <input>
+	for i, loc := range sm {
+		if loc.File != "<input>" {
+			t.Errorf("entry %d: file = %q, want %q", i, loc.File, "<input>")
+		}
+		if loc.Line != i+1 {
+			t.Errorf("entry %d: line = %d, want %d", i, loc.Line, i+1)
+		}
+	}
+}
+
+func TestSourceMapWithInclude(t *testing.T) {
+	tmpDir := t.TempDir()
+
+	// Create included file (2 lines + trailing newline = 3 entries after split)
+	os.WriteFile(filepath.Join(tmpDir, "inc.occ"), []byte("incA\nincB\n"), 0644)
+
+	// Create main file: line1, #INCLUDE, line3
+	mainContent := "line1\n#INCLUDE \"inc.occ\"\nline3\n"
+	mainFile := filepath.Join(tmpDir, "main.occ")
+	os.WriteFile(mainFile, []byte(mainContent), 0644)
+
+	pp := New()
+	out, err := pp.ProcessFile(mainFile)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	sm := pp.SourceMap()
+	outLines := strings.Split(out, "\n")
+	if len(sm) != len(outLines) {
+		t.Fatalf("source map length %d != output lines %d", len(sm), len(outLines))
+	}
+
+	// Entry 0: main.occ line 1
+	if sm[0].Line != 1 || !strings.HasSuffix(sm[0].File, "main.occ") {
+		t.Errorf("entry 0: got {%s, %d}, want {main.occ, 1}", sm[0].File, sm[0].Line)
+	}
+
+	// Entries 1-3: inc.occ lines 1-3
+	incFile := filepath.Join(tmpDir, "inc.occ")
+	for i := 1; i <= 3; i++ {
+		if sm[i].File != incFile {
+			t.Errorf("entry %d: file = %q, want %q", i, sm[i].File, incFile)
+		}
+		if sm[i].Line != i {
+			t.Errorf("entry %d: line = %d, want %d", i, sm[i].Line, i)
+		}
+	}
+
+	// Entry 4: main.occ line 3
+	if sm[4].Line != 3 || !strings.HasSuffix(sm[4].File, "main.occ") {
+		t.Errorf("entry 4: got {%s, %d}, want {main.occ, 3}", sm[4].File, sm[4].Line)
+	}
+}
$ Bash
Show recent commit messages
git log --oneline -5
d6b665f Merge pull request #38 from codeassociates/docs/gen-module-limitation
cb5f541 Document gen-module regex-based limitation
e8c9709 Merge pull request #36 from codeassociates/fix/readme-course-module-step
25a045a Add missing gen-module step to course module instructions
2012236 Merge pull request #34 from codeassociates/fix/readme-kroc-link
$ Bash
Create branch and commit changes
git checkout -b feat/source-map-error-messages && git add main.go preproc/preproc.go preproc/preproc_test.go && git commit -m "$(cat <<'EOF'
Add source file tracking to parser error messages

Build a source map in the preprocessor during expansion so that parser
errors show original file:line instead of expanded line numbers. This
makes errors far easier to diagnose when #INCLUDE pulls in large files.

Closes #41

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
EOF
)"
Switched to a new branch 'feat/source-map-error-messages'
3 files changed, 143 insertions(+), 4 deletions(-)
$ Bash
Push branch to remote
git push -u origin feat/source-map-error-messages
remote: 
remote: Create a pull request for 'feat/source-map-error-messages' on GitHub by visiting:        
remote:      https://github.com/codeassociates/occam2go/pull/new/feat/source-map-error-messages        
remote: 
To associate-1.github.com:codeassociates/occam2go.git
 * [new branch]      feat/source-map-error-messages -> feat/source-map-error-messages
branch 'feat/source-map-error-messages' set up to track 'origin/feat/source-map-error-messages'.
$ Bash
Create pull request
gh pr create --title "Add source file tracking to parser error messages" --body "$(cat <<'EOF'
## Summary

- Build a source map in the preprocessor during expansion, mapping each expanded output line back to its original file and line number
- Translate parser error messages from `line NNN:` to `filename:line:` format using the source map
- Errors in `#INCLUDE`d files now correctly point to the included file, not the expanded line number

Closes #41

## Test plan

- [x] New unit tests: `TestSourceMapSimple`, `TestSourceMapWithDirectives`, `TestSourceMapWithInclude`
- [x] All existing tests pass (`go test ./...`)
- [x] Course module still transpiles and passes `go vet`
- [ ] Manual test: trigger a parse error in a file with `#INCLUDE` and verify the error points to the correct source file and line

🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"
https://github.com/codeassociates/occam2go/pull/42

PR created: https://github.com/codeassociates/occam2go/pull/42

Can you try the manual test with the example file cited in the original bug report?

Thinking

The user wants me to test with the example from the bug report. Let me check issue #41 to see what file was mentioned.

$ Bash
View issue #41 for the example file
gh issue view 41
title:	Parser error messages don't identify source file
state:	OPEN
author:	dboreham
labels:	
comments:	0
assignees:	
projects:	
milestone:	
number:	41
--
I noticed while testing which of the kroc course module example programs transpile and run ok that the parser's error output is not very useful. For example:
```
$ ./occam2go -I kroc/modules/course/libsrc -D TARGET.BITS.PER.WORD=32 -o test.go kroc/modules/course/examples/test_utils.occ
Parse errors:
  line 4335: expected type after VAL, got [
  line 4335: unexpected token: INT
  line 4335: unexpected token: ]
  line 4391: expected ), got STRING
  line 4391: unexpected token: STRING
  line 4391: unexpected token: *
```

Since the test_utils.occ program is quite short, these messages about line 4335 are presumably to do with the course library itself?
Actually I'm a bit confused by this because the course library parses ok in other cases, when I try to transpile double_echo.occ for example it works ok.
The same #include is used in both programs. Anyway it would be useful to get a better error message that identifies the source file. This would aid in diagnosis.
$ Bash
Run the exact example from issue #41
./occam2go -I kroc/modules/course/libsrc -D TARGET.BITS.PER.WORD=32 -o /tmp/test.go kroc/modules/course/examples/test_utils.occ 2>&1
Exit code 1
Parse errors:
  kroc/modules/course/examples/test_utils.occ:19: expected type after VAL, got [
  kroc/modules/course/examples/test_utils.occ:19: unexpected token: INT
  kroc/modules/course/examples/test_utils.occ:19: unexpected token: ]
  kroc/modules/course/examples/test_utils.occ:75: expected ), got STRING
  kroc/modules/course/examples/test_utils.occ:75: unexpected token: STRING
  kroc/modules/course/examples/test_utils.occ:75: unexpected token: *
  kroc/modules/course/examples/test_utils.occ:75: unexpected token: STRING
  kroc/modules/course/examples/test_utils.occ:75: unexpected token: ,
  kroc/modules/course/examples/test_utils.occ:75: unexpected token: INT
  kroc/modules/course/examples/test_utils.occ:75: unexpected token: ,
  kroc/modules/course/examples/test_utils.occ:75: unexpected token in expression: )

Parse errors:
  kroc/modules/course/examples/test_utils.occ:19: expected type after VAL, got [
  kroc/modules/course/examples/test_utils.occ:19: unexpected token: INT
  kroc/modules/course/examples/test_utils.occ:19: unexpected token: ]
  kroc/modules/course/examples/test_utils.occ:75: expected ), got STRING
  kroc/modules/course/examples/test_utils.occ:75: unexpected token: STRING
  kroc/modules/course/examples/test_utils.occ:75: unexpected token: *
  kroc/modules/course/examples/test_utils.occ:75: unexpected token: STRING
  kroc/modules/course/examples/test_utils.occ:75: unexpected token: ,
  kroc/modules/course/examples/test_utils.occ:75: unexpected token: INT
  kroc/modules/course/examples/test_utils.occ:75: unexpected token: ,
  kroc/modules/course/examples/test_utils.occ:75: unexpected token in expression: )

The errors now correctly point to kroc/modules/course/examples/test_utils.occ at lines 19 and 75 — instead of the unhelpful line 4335 / line 4391 from the expanded output. The errors are actually in the user's source file, not the course library, which answers the question from the bug report.

One thing I notice: the errors are printed twice. Let me check why.

Read
{
  "file_path": "/home/david/projects/code-associates/occam2go/main.go",
  "offset": 84,
  "limit": 30
}
    84→		os.Exit(1)
    85→	}
    86→	if len(pp.Errors()) > 0 {
    87→		fmt.Fprintf(os.Stderr, "Preprocessor warnings:\n")
    88→		for _, e := range pp.Errors() {
    89→			fmt.Fprintf(os.Stderr, "  %s\n", e)
    90→		}
    91→	}
    92→
    93→	// Lex
    94→	l := lexer.New(expanded)
    95→
    96→	// Parse
    97→	p := parser.New(l)
    98→	program := p.ParseProgram()
    99→
   100→	if len(p.Errors()) > 0 {
   101→		fmt.Fprintf(os.Stderr, "Parse errors:\n")
   102→		sourceMap := pp.SourceMap()
   103→		for _, err := range p.Errors() {
   104→			fmt.Fprintf(os.Stderr, "  %s\n", translateError(err, sourceMap))
   105→		}
   106→		os.Exit(1)
   107→	}
   108→
   109→	// Generate Go code
   110→	gen := codegen.New()
   111→	output := gen.Generate(program)
   112→
   113→	// Write output

The double printing isn't from our code — it only prints once at line 100-106. Let me check if the parser itself prints errors.

Grep
{
  "pattern": "fmt\\.Fprint.*Stderr.*error|fmt\\.Fprint.*Stderr.*Error",
  "path": "/home/david/projects/code-associates/occam2go/parser",
  "output_mode": "content"
}
No matches found
Grep
{
  "pattern": "fmt\\.(Print|Fprint)",
  "path": "/home/david/projects/code-associates/occam2go/codegen",
  "output_mode": "content"
}
codegen/codegen_test.go:286:	if !strings.Contains(output, `fmt.Fprintln(os.Stderr, "STOP encountered")`) {
codegen/codegen_test.go:287:		t.Errorf("expected fmt.Fprintln(os.Stderr, ...) in output, got:\n%s", output)
codegen/codegen_test.go:360:	if !strings.Contains(output, `fmt.Println("hello")`) {
codegen/codegen_test.go:361:		t.Errorf("expected 'fmt.Println(\"hello\")' in output, got:\n%s", output)
codegen/codegen.go:984:		g.writeLine(`fmt.Fprintln(os.Stderr, "STOP encountered")`)
codegen/codegen.go:1955:		g.write("fmt.Println(")
codegen/codegen.go:1961:		g.write("fmt.Println()")
Grep
{
  "pattern": "Parse error|parse error",
  "path": "/home/david/projects/code-associates/occam2go/codegen",
  "output_mode": "content"
}
No matches found
$ Bash
Count output lines to confirm duplication
./occam2go -I kroc/modules/course/libsrc -D TARGET.BITS.PER.WORD=32 -o /tmp/test.go kroc/modules/course/examples/test_utils.occ 2>&1 | wc -l
12
$ Bash
Inspect output with special chars visible
./occam2go -I kroc/modules/course/libsrc -D TARGET.BITS.PER.WORD=32 -o /tmp/test.go kroc/modules/course/examples/test_utils.occ 2>&1 | cat -A | head -15
Parse errors:$
  kroc/modules/course/examples/test_utils.occ:19: expected type after VAL, got [$
  kroc/modules/course/examples/test_utils.occ:19: unexpected token: INT$
  kroc/modules/course/examples/test_utils.occ:19: unexpected token: ]$
  kroc/modules/course/examples/test_utils.occ:75: expected ), got STRING$
  kroc/modules/course/examples/test_utils.occ:75: unexpected token: STRING$
  kroc/modules/course/examples/test_utils.occ:75: unexpected token: *$
  kroc/modules/course/examples/test_utils.occ:75: unexpected token: STRING$
  kroc/modules/course/examples/test_utils.occ:75: unexpected token: ,$
  kroc/modules/course/examples/test_utils.occ:75: unexpected token: INT$
  kroc/modules/course/examples/test_utils.occ:75: unexpected token: ,$
  kroc/modules/course/examples/test_utils.occ:75: unexpected token in expression: )$

Only 12 lines — it's not actually duplicated. The earlier output I saw was an artifact of the tool display appending stderr twice. The actual output is correct:

Parse errors:
  kroc/modules/course/examples/test_utils.occ:19: expected type after VAL, got [
  kroc/modules/course/examples/test_utils.occ:19: unexpected token: INT
  kroc/modules/course/examples/test_utils.occ:19: unexpected token: ]
  kroc/modules/course/examples/test_utils.occ:75: expected ), got STRING
  ...

Before this change, the same errors showed line 4335 and line 4391. Now they correctly point to test_utils.occ lines 19 and 75 — the actual locations in the user's source file.

Ok great. Can you update the task list in the PR description to reflect that you've performed the manual test now?

$ Bash
Update PR description to check off manual test
gh pr edit 42 --body "$(cat <<'EOF'
## Summary

- Build a source map in the preprocessor during expansion, mapping each expanded output line back to its original file and line number
- Translate parser error messages from `line NNN:` to `filename:line:` format using the source map
- Errors in `#INCLUDE`d files now correctly point to the included file, not the expanded line number

Closes #41

## Test plan

- [x] New unit tests: `TestSourceMapSimple`, `TestSourceMapWithDirectives`, `TestSourceMapWithInclude`
- [x] All existing tests pass (`go test ./...`)
- [x] Course module still transpiles and passes `go vet`
- [x] Manual test: trigger a parse error in a file with `#INCLUDE` and verify the error points to the correct source file and line

🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"
Exit code 1
GraphQL: Your token has not been granted the required scopes to execute this query. The 'login' field requires one of the following scopes: ['read:org'], but your token has only been granted the: ['notifications', 'project', 'repo', 'workflow', 'write:discussion'] scopes. Please modify your token's scopes at: https://github.com/settings/tokens.

GraphQL: Your token has not been granted the required scopes to execute this query. The 'login' field requires one of the following scopes: ['read:org'], but your token has only been granted the: ['notifications', 'project', 'repo', 'workflow', 'write:discussion'] scopes. Please modify your token's scopes at: https://github.com/settings/tokens.
$ Bash
Update PR body via API
gh api repos/codeassociates/occam2go/pulls/42 --method PATCH --field body="$(cat <<'EOF'
## Summary

- Build a source map in the preprocessor during expansion, mapping each expanded output line back to its original file and line number
- Translate parser error messages from `line NNN:` to `filename:line:` format using the source map
- Errors in `#INCLUDE`d files now correctly point to the included file, not the expanded line number

Closes #41

## Test plan

- [x] New unit tests: `TestSourceMapSimple`, `TestSourceMapWithDirectives`, `TestSourceMapWithInclude`
- [x] All existing tests pass (`go test ./...`)
- [x] Course module still transpiles and passes `go vet`
- [x] Manual test: trigger a parse error in a file with `#INCLUDE` and verify the error points to the correct source file and line

🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)" --jq '.html_url'
https://github.com/codeassociates/occam2go/pull/42

Done — the manual test checkbox is now checked off in the PR.

Thank you. I've approved the PR and CI checks have passed. Please merge.

$ Bash
Merge PR #42
gh pr merge 42 --merge

Merged. PR #42 is now in main.