Implement the following plan:
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.
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.
preproc/preproc.go — Build source map during expansionSourceLoc struct: {File string, Line int}sourceMap []SourceLoc field to PreprocessorSourceMap() []SourceLoc getter methodprocessSource (new parameter) and ProcessFileProcessSource passes "<input>" as the filename (no file context)SourceLoc{filename, originalLine} entry#INCLUDE lines: the recursive ProcessFile → processSource call populates entries for included content; the current line does NOT get an entry#DEFINE, #IF, #ELSE, #ENDIF, #COMMENT, etc.): append {filename, lineNum}{filename, lineNum}{filename, lineNum}main.go — Translate error line numbers using source mappp.SourceMap()"line NNN: ...", look up NNN in the source map"filename:line: ..." format (standard compiler error format)"filename:line:" or the full pathpreproc/preproc_test.go — Test source mapProcessFile with #INCLUDE produces correct source map entriesProcessSource uses "<input>" as filenamepreproc/preproc.go — add SourceLoc, sourceMap field, pass filename, build mapmain.go — translate error messages using source mappreproc/preproc_test.go — add source map testsgo 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
Let me start by reading the key files I need to modify to understand the current implementation.
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→ 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→{
"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→Now I have a clear understanding of the code. Let me implement the plan:
SourceLoc struct and source map to preprocessorprocessSource and build the mapmain.go to translate error line numbersLet 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.
{
"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)))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:
preproc/preproc.go - Add SourceLoc, sourceMap, pass filename through processSourcemain.go - Translate error line numberspreproc/preproc_test.go - Add source map testsLet 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.
{
"subject": "Add SourceLoc and source map to preprocessor",
"activeForm": "Adding source map to preprocessor"
}{
"subject": "Translate parser error line numbers in main.go",
"activeForm": "Updating main.go error translation"
}{
"subject": "Add source map tests",
"activeForm": "Adding source map tests"
}{
"subject": "Run tests and verify",
"activeForm": "Running tests"
}// 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.
// 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.
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.
// 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.
// 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.
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.
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.
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.
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.
? 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
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:
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"
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:
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"
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:
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"
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
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")
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)
+ }
+}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
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(-)
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'.
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 )"
The user wants me to test with the example from the bug report. Let me check issue #41 to see what file was mentioned.
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.
./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.
{
"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 outputThe double printing isn't from our code — it only prints once at line 100-106. Let me check if the parser itself prints errors.
{
"pattern": "fmt\\.Fprint.*Stderr.*error|fmt\\.Fprint.*Stderr.*Error",
"path": "/home/david/projects/code-associates/occam2go/parser",
"output_mode": "content"
}{
"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()"){
"pattern": "Parse error|parse error",
"path": "/home/david/projects/code-associates/occam2go/codegen",
"output_mode": "content"
}./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
./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?
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.
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'