Skip to content

Commit a975d51

Browse files
evanphxclaude
andcommitted
Add detailed parse error reporting with option pattern
- Add ParseError type with position info and human-readable expectations - Add Parse() function with options (WithErrors, WithPartialMatch, WithFilename) - Extend Parser.Parse() and Parser.ParseFile() to support options - Maintain backward compatibility: no error collection by default - Add comprehensive test coverage for error reporting features - Support line/column positioning and input preview in error messages 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 683f2fc commit a975d51

File tree

3 files changed

+626
-38
lines changed

3 files changed

+626
-38
lines changed

errors.go

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
package peggysue
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
)
7+
8+
// ParseError represents a detailed parse failure with position and expectation information
9+
type ParseError struct {
10+
Input string
11+
MaxPos int
12+
Failures []RuleFailure
13+
filename string
14+
linePos []int
15+
}
16+
17+
// RuleFailure represents a single rule that failed to match at a specific position
18+
type RuleFailure struct {
19+
Pos int
20+
Rule Rule
21+
Expected string // Human-readable expectation
22+
}
23+
24+
// Error implements the error interface
25+
func (e *ParseError) Error() string {
26+
if len(e.Failures) == 0 {
27+
return fmt.Sprintf("parse failed at position %d", e.MaxPos)
28+
}
29+
30+
// Find the furthest failures for the most relevant error
31+
furthest := e.getFurthestFailures()
32+
33+
// Use the actual failure position, not necessarily MaxPos
34+
errorPos := e.MaxPos
35+
if len(furthest) > 0 {
36+
errorPos = furthest[0].Pos
37+
}
38+
39+
line, col := e.lineCol(errorPos)
40+
preview := e.getPreview(errorPos)
41+
42+
var expectations []string
43+
seen := make(map[string]bool)
44+
45+
for _, f := range furthest {
46+
exp := f.Expected
47+
if exp == "" {
48+
exp = Print(f.Rule)
49+
}
50+
if !seen[exp] {
51+
seen[exp] = true
52+
expectations = append(expectations, exp)
53+
}
54+
}
55+
56+
var msg strings.Builder
57+
if e.filename != "" {
58+
fmt.Fprintf(&msg, "%s:%d:%d: ", e.filename, line, col)
59+
} else {
60+
fmt.Fprintf(&msg, "line %d, column %d: ", line, col)
61+
}
62+
63+
fmt.Fprintf(&msg, "expected %s", formatExpectations(expectations))
64+
65+
if preview != "" {
66+
fmt.Fprintf(&msg, "\n%s", preview)
67+
}
68+
69+
return msg.String()
70+
}
71+
72+
// Position returns the furthest position reached during parsing
73+
func (e *ParseError) Position() (line, col int) {
74+
return e.lineCol(e.MaxPos)
75+
}
76+
77+
// getFurthestFailures returns failures at the maximum position
78+
func (e *ParseError) getFurthestFailures() []RuleFailure {
79+
var furthest []RuleFailure
80+
for _, f := range e.Failures {
81+
if f.Pos == e.MaxPos {
82+
furthest = append(furthest, f)
83+
}
84+
}
85+
return furthest
86+
}
87+
88+
// lineCol converts a byte position to line and column numbers
89+
func (e *ParseError) lineCol(pos int) (line, col int) {
90+
line = 1
91+
lastNewline := -1
92+
93+
for _, nlPos := range e.linePos {
94+
if nlPos >= pos {
95+
break
96+
}
97+
line++
98+
lastNewline = nlPos
99+
}
100+
101+
col = pos - lastNewline
102+
return line, col
103+
}
104+
105+
// getPreview returns a preview of the input around the error position
106+
func (e *ParseError) getPreview(pos int) string {
107+
if pos >= len(e.Input) {
108+
pos = len(e.Input) - 1
109+
}
110+
if pos < 0 {
111+
return ""
112+
}
113+
114+
// Find start of line
115+
start := pos
116+
for start > 0 && e.Input[start-1] != '\n' {
117+
start--
118+
}
119+
120+
// Find end of line
121+
end := pos
122+
for end < len(e.Input) && e.Input[end] != '\n' {
123+
end++
124+
}
125+
126+
if start >= end {
127+
return ""
128+
}
129+
130+
line := e.Input[start:end]
131+
pointer := strings.Repeat(" ", pos-start) + "^"
132+
133+
return fmt.Sprintf(" %s\n %s", line, pointer)
134+
}
135+
136+
// formatExpectations formats a list of expectations into a readable string
137+
func formatExpectations(expectations []string) string {
138+
switch len(expectations) {
139+
case 0:
140+
return "valid input"
141+
case 1:
142+
return expectations[0]
143+
case 2:
144+
return expectations[0] + " or " + expectations[1]
145+
default:
146+
return strings.Join(expectations[:len(expectations)-1], ", ") + ", or " + expectations[len(expectations)-1]
147+
}
148+
}
149+
150+
// ParseOption configures parsing behavior
151+
type ParseOption func(*parseConfig)
152+
153+
type parseConfig struct {
154+
collectErrors bool
155+
partialOk bool
156+
debug bool
157+
filename string
158+
}
159+
160+
// WithErrors enables detailed error collection during parsing
161+
func WithErrors() ParseOption {
162+
return func(c *parseConfig) {
163+
c.collectErrors = true
164+
}
165+
}
166+
167+
// WithPartialMatch allows partial matching (doesn't require full input consumption)
168+
func WithPartialMatch() ParseOption {
169+
return func(c *parseConfig) {
170+
c.partialOk = true
171+
}
172+
}
173+
174+
// WithFilename sets the filename for error reporting
175+
func WithFilename(filename string) ParseOption {
176+
return func(c *parseConfig) {
177+
c.filename = filename
178+
}
179+
}
180+
181+
// Parse attempts to match the given rule against the input string with options.
182+
// By default, no error tracking is performed for backward compatibility.
183+
// Use WithErrors() to enable detailed error reporting.
184+
func Parse(r Rule, input string, opts ...ParseOption) (val any, matched bool, err error) {
185+
config := parseConfig{
186+
collectErrors: false, // default to no error collection for backward compatibility
187+
partialOk: false, // default to full match required
188+
}
189+
190+
for _, opt := range opts {
191+
opt(&config)
192+
}
193+
194+
s := &state{
195+
input: input,
196+
inputSize: len(input),
197+
pos: 0,
198+
maxPos: 0,
199+
filename: config.filename,
200+
collectErrors: config.collectErrors,
201+
linePos: computeLines(input),
202+
}
203+
204+
if config.collectErrors {
205+
s.failures = make([]RuleFailure, 0)
206+
}
207+
208+
s.setupBadGood(config.debug)
209+
210+
res := s.match(r)
211+
212+
if !res.matched {
213+
if config.collectErrors && len(s.failures) > 0 {
214+
return nil, false, &ParseError{
215+
Input: input,
216+
MaxPos: s.maxPos,
217+
Failures: s.failures,
218+
filename: config.filename,
219+
linePos: s.linePos,
220+
}
221+
}
222+
return nil, false, nil
223+
}
224+
225+
if !config.partialOk && s.pos != len(input) {
226+
if config.collectErrors {
227+
return res.value, false, &ParseError{
228+
Input: input,
229+
MaxPos: s.pos,
230+
Failures: []RuleFailure{{Pos: s.pos, Expected: "end of input"}},
231+
filename: config.filename,
232+
linePos: s.linePos,
233+
}
234+
}
235+
236+
// Maintain backward compatibility with existing error
237+
return res.value, false, &ErrInputNotConsumed{
238+
MaxPos: s.maxPos,
239+
MaxRule: s.maxRule,
240+
}
241+
}
242+
243+
return res.value, true, nil
244+
}

0 commit comments

Comments
 (0)