Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions _examples/recovery/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// Package main demonstrates error recovery in participle.
//
// Error recovery allows the parser to continue parsing after encountering errors,
// collecting multiple errors and producing a partial AST. This is particularly
// useful for IDE integration, linters, and providing comprehensive error messages.
//
// This example shows how to parse a simple programming language with deliberate
// syntax errors and recover from them using various recovery strategies.
package main

import (
"errors"
"fmt"

"github.com/alecthomas/participle/v2"
"github.com/alecthomas/participle/v2/lexer"
)

// Grammar for a simple statement-based language
type Program struct {
Statements []*Statement `parser:"@@*"`
}

type Statement struct {
Pos lexer.Position
Recovered bool // Set to true if this statement was recovered
RecoveredSpan lexer.Position // Position where recovery started

VarDecl *VarDecl `parser:" @@"`
FuncCall *FuncCall `parser:"| @@"`
}

type VarDecl struct {
Keyword string `parser:"@\"let\""`
Name string `parser:"@Ident"`
Eq string `parser:"@\"=\""`
Value *Expr `parser:"@@"`
Semi string `parser:"@\";\""`
}

type FuncCall struct {
Name string `parser:"@Ident"`
Args []*Expr `parser:"\"(\" (@@ (\",\" @@)*)? \")\""`
Semi string `parser:"@\";\""`
}

type Expr struct {
Number *int `parser:" @Number"`
String *string `parser:"| @String"`
Ident string `parser:"| @Ident"`
}

var (
simpleLexer = lexer.MustSimple([]lexer.SimpleRule{
{"whitespace", `\s+`},
{"String", `"[^"]*"`},
{"Number", `\d+`},
{"Ident", `[a-zA-Z_][a-zA-Z0-9_]*`},
{"Punct", `[;=(),]`},
})

parser = participle.MustBuild[Program](
participle.Lexer(simpleLexer),
)
)

func main() {
fmt.Println("=== Example 1: Valid input ===")
runExample(`
let x = 42;
let y = 100;
print(x);
`)

fmt.Println("\n=== Example 2: Input with errors (no recovery) ===")
runExampleNoRecovery(`
let x = 42;
let y = ;
let z = 100;
`)

fmt.Println("\n=== Example 3: Input with errors (with recovery) ===")
runExample(`
let x = 42;
let y = ;
let z = 100;
`)

fmt.Println("\n=== Example 4: Multiple errors with recovery ===")
runExample(`
let x = 42;
let = 100;
let y = ;
print(a);
let z = 50;
`)
}

func runExample(input string) {
fmt.Println("Input:", input)

// Parse with error recovery enabled
// SkipPast skips tokens until a sync token is found and consumes it.
// This allows recovery to the next statement after encountering an error.
ast, err := parser.ParseString("example.lang", input,
participle.Recover(
participle.SkipPast(";"),
),
)

printResult(ast, err)
}

func runExampleNoRecovery(input string) {
fmt.Println("Input:", input)

// Parse WITHOUT recovery - stops at first error
ast, err := parser.ParseString("example.lang", input)

printResult(ast, err)
}

func printResult(ast *Program, err error) {
// Print what we were able to parse
if ast != nil {
fmt.Printf("Parsed %d statements:\n", len(ast.Statements))
for i, stmt := range ast.Statements {
recoveredInfo := ""
if stmt.Recovered {
recoveredInfo = fmt.Sprintf(" [RECOVERED at %v]", stmt.RecoveredSpan)
}

if stmt.VarDecl != nil {
value := "?"
if stmt.VarDecl.Value != nil {
if stmt.VarDecl.Value.Number != nil {
value = fmt.Sprintf("%d", *stmt.VarDecl.Value.Number)
} else if stmt.VarDecl.Value.String != nil {
value = *stmt.VarDecl.Value.String
} else if stmt.VarDecl.Value.Ident != "" {
value = stmt.VarDecl.Value.Ident
}
}
name := stmt.VarDecl.Name
if name == "" {
name = "<missing>"
}
fmt.Printf(" %d. VarDecl: let %s = %s%s\n", i+1, name, value, recoveredInfo)
} else if stmt.FuncCall != nil {
fmt.Printf(" %d. FuncCall: %s(...)%s\n", i+1, stmt.FuncCall.Name, recoveredInfo)
}
}
} else {
fmt.Println("No AST produced")
}

// Handle errors
if err != nil {
fmt.Println("Errors:")
var recErr *participle.RecoveryError
if errors.As(err, &recErr) {
// Multiple errors were recovered
for i, e := range recErr.Errors {
fmt.Printf(" %d. %v\n", i+1, e)
}
} else {
// Single error
fmt.Printf(" - %v\n", err)
}
} else {
fmt.Println("No errors!")
}
}
49 changes: 49 additions & 0 deletions _examples/recovery/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package main

import (
"errors"
"testing"

"github.com/alecthomas/assert/v2"

"github.com/alecthomas/participle/v2"
)

func TestRecoveryExample(t *testing.T) {
// Valid input
t.Run("ValidInput", func(t *testing.T) {
input := `let x = 42; let y = 100;`
ast, err := parser.ParseString("test", input,
participle.Recover(participle.SkipPast(";")),
)
assert.NoError(t, err)
assert.NotZero(t, ast)
assert.Equal(t, 2, len(ast.Statements))
})

// Input with error - recovery enabled
t.Run("ErrorWithRecovery", func(t *testing.T) {
input := `let x = 42; let y = ; let z = 100;`
ast, err := parser.ParseString("test", input,
participle.Recover(participle.SkipPast(";")),
)
// Should have errors but also parsed everything
var recErr *participle.RecoveryError
assert.True(t, errors.As(err, &recErr))
assert.Equal(t, 1, len(recErr.Errors))
assert.NotZero(t, ast)
assert.Equal(t, 3, len(ast.Statements))
})

// Input with error - recovery disabled
t.Run("ErrorWithoutRecovery", func(t *testing.T) {
input := `let x = 42; let y = ; let z = 100;`
ast, err := parser.ParseString("test", input)
// Should error and not be a RecoveryError
assert.Error(t, err)
var recErr *participle.RecoveryError
assert.False(t, errors.As(err, &recErr))
// Partial AST is still returned
assert.NotZero(t, ast)
})
}
46 changes: 46 additions & 0 deletions context.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ type parseContext struct {
caseInsensitive map[lexer.TokenType]bool
apply []*contextFieldSet
allowTrailing bool

// Error recovery support
recovery *recoveryConfig
recoveryErrors []error
}

func newParseContext(lex *lexer.PeekingLexer, lookahead int, caseInsensitive map[lexer.TokenType]bool) parseContext {
Expand Down Expand Up @@ -71,13 +75,16 @@ func (p *parseContext) Accept(branch *parseContext) {
p.deepestErrorDepth = branch.deepestErrorDepth
p.deepestError = branch.deepestError
}
// Merge recovery errors from the branch
p.recoveryErrors = append(p.recoveryErrors, branch.recoveryErrors...)
}

// Branch starts a new lookahead branch.
func (p *parseContext) Branch() *parseContext {
branch := &parseContext{}
*branch = *p
branch.apply = nil
branch.recoveryErrors = nil // Don't share slice with parent
return branch
}

Expand Down Expand Up @@ -126,3 +133,42 @@ func maxInt(a, b int) int {
}
return b
}

// Recovery support methods

// recoveryEnabled returns true if error recovery is enabled.
func (p *parseContext) recoveryEnabled() bool {
return p.recovery != nil && len(p.recovery.strategies) > 0
}

// addRecoveryError records an error that occurred during recovery.
func (p *parseContext) addRecoveryError(err error) {
p.recoveryErrors = append(p.recoveryErrors, err)
}

// tryRecover attempts to recover from a parse error using configured strategies.
// Returns true if recovery was successful.
func (p *parseContext) tryRecover(err error, parent reflect.Value) (bool, []reflect.Value) {
if !p.recoveryEnabled() {
return false, nil
}

// Check if we've exceeded max errors
if p.recovery.maxErrors > 0 && len(p.recoveryErrors) >= p.recovery.maxErrors {
return false, nil
}

// Try each strategy in order
for _, strategy := range p.recovery.strategies {
checkpoint := p.PeekingLexer.MakeCheckpoint()
recovered, values, newErr := strategy.Recover(p, err, parent)
if recovered {
p.addRecoveryError(newErr)
return true, values
}
// Restore checkpoint if strategy failed
p.PeekingLexer.LoadCheckpoint(checkpoint)
}

return false, nil
}
4 changes: 4 additions & 0 deletions ebnf.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,10 @@ func buildEBNF(root bool, n node, seen map[node]bool, p *ebnfp, outp *[]*ebnfp)
buildEBNF(true, n.expr, seen, p, outp)
p.out += ")"

case *recoveryNode:
// Recovery wrapper is transparent for EBNF generation
buildEBNF(root, n.inner, seen, p, outp)

default:
panic(fmt.Sprintf("unsupported node type %T", n))
}
Expand Down
13 changes: 11 additions & 2 deletions grammar.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,13 +237,21 @@ func (g *generatorContext) parseCapture(slexer *structLexer) (node, error) {
return nil, err
}
field := slexer.Field()

// Parse recovery tag if present
recoveryConfig, err := parseRecoveryTag(field.RecoveryTag())
if err != nil {
return nil, fmt.Errorf("%s: invalid recover tag: %w", field.Name, err)
}

if token.Type == '@' {
_, _ = slexer.Next()
n, err := g.parseType(field.Type)
if err != nil {
return nil, err
}
return &capture{field, n}, nil
captureNode := &capture{field, n}
return wrapWithRecovery(captureNode, recoveryConfig), nil
}
ft := indirectType(field.Type)
if ft.Kind() == reflect.Struct && ft != tokenType && ft != tokensType && !implements(ft, captureType) && !implements(ft, textUnmarshalerType) {
Expand All @@ -253,7 +261,8 @@ func (g *generatorContext) parseCapture(slexer *structLexer) (node, error) {
if err != nil {
return nil, err
}
return &capture{field, n}, nil
captureNode := &capture{field, n}
return wrapWithRecovery(captureNode, recoveryConfig), nil
}

// A reference in the form <identifier> refers to a named token from the lexer.
Expand Down
Loading