Keep the Last Good Tree While the User Types
Problem. An editor shows something built from the tree of the text (an outline, highlighting, a list of symbols), and the tree is rebuilt after every keystroke. Most states of a half-typed line are syntax errors, which return no tree. You want to keep showing the last tree that parsed, and to see how little work each keystroke costs.
Wrap a Document in a small Editor type. It applies each change, reparses, keeps the last tree that parsed, and
reports what the parse cost. For a grammar that recovers from errors and returns a tree for every keystroke, see
Reparse a document on every keystroke instead. The program here simulates a user who opens a blank
line in the middle of a 1,000-line settings file and types a new setting one key at a time, with an error after almost
every key, and then retypes a value:
// main.go
package main
import (
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"github.com/ornew/pego"
)
const grammar = `package settings
type Setting = { Key Key; Value Value }
type Key = node[string]
type Value = node[string]
@start
rule main = (setting / blank)* $$
rule setting Setting = k:key " = " v:value "\n" => Setting{Key: $k, Value: $v}
rule key Key = ([a-z_]+) #atomic
rule value Value = ([0-9]+) #atomic
rule blank = "\n" `
// lineStart returns the position, in code points, of the first character of the 1-based line.
func lineStart(text string, line int) int {
pos := 0
for _, r := range text {
if line == 1 {
break
}
pos++
if r == '\n' {
line--
}
}
return pos
}
// Editor keeps a Document and the last tree that parsed.
type Editor struct {
doc *pego.Document
last *pego.Node
}
// Apply replaces [start, end) with text and reparses.
func (e *Editor) Apply(start, end int, text string) error {
if err := e.doc.Edit(start, end, text); err != nil {
return err
}
node, err := e.doc.Parse()
st := e.doc.Stats()
if node != nil {
e.last = node // a tree, possibly with recovered errors
}
var status string
switch {
case err == nil:
status = fmt.Sprintf("ok, %d entries", node.Elem(0).Len())
default:
var se *pego.SyntaxError
if errors.As(err, &se) {
status = fmt.Sprintf("error at %d:%d, keeping the last good tree", se.Line, se.Col)
} else {
status = "error: " + err.Error()
}
}
fmt.Printf("%-18q evaluated %4d reused %4d %s\n", text, st.Evaluated, st.Reused, status)
return nil
}
func main() {
p, err := pego.CompileSource(grammar, "main")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
var b strings.Builder
for i := 0; i < 1000; i++ {
fmt.Fprintf(&b, "key_%c = %d\n", 'a'+i%26, i)
}
doc, err := p.NewDocument(b.String())
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
node, err := doc.Parse()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Printf("%-18s evaluated %4d reused %4d ok, %d entries\n", "(first parse)", doc.Stats().Evaluated, doc.Stats().Reused, node.Elem(0).Len())
ed := &Editor{doc: doc, last: node}
// Open a blank line before line 500, then type a new setting into it, one keystroke at a time.
pos := lineStart(doc.Text(), 500)
if err := ed.Apply(pos, pos, "\n"); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
for _, ch := range "timeout = 30" {
if err := ed.Apply(pos, pos, string(ch)); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
pos++
}
// Change the value 30 to 45 with a single replacement of two characters.
if err := ed.Apply(pos-2, pos, "45"); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
// The incremental result is the same as a fresh parse.
got, _ := json.Marshal(ed.last)
fresh, err := p.Parse(doc.Text())
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
want, _ := json.Marshal(fresh)
fmt.Println("same as a fresh parse:", string(got) == string(want))
}
(first parse) evaluated 3003 reused 0 ok, 1000 entries
"\n" evaluated 4 reused 1001 ok, 1001 entries
"t" evaluated 3 reused 499 error at 500:2, keeping the last good tree
"i" evaluated 3 reused 499 error at 500:3, keeping the last good tree
"m" evaluated 3 reused 499 error at 500:4, keeping the last good tree
"e" evaluated 3 reused 499 error at 500:5, keeping the last good tree
"o" evaluated 3 reused 499 error at 500:6, keeping the last good tree
"u" evaluated 3 reused 499 error at 500:7, keeping the last good tree
"t" evaluated 3 reused 499 error at 500:8, keeping the last good tree
" " evaluated 3 reused 499 error at 500:8, keeping the last good tree
"=" evaluated 2 reused 500 error at 500:8, keeping the last good tree
" " evaluated 3 reused 500 error at 500:11, keeping the last good tree
"3" evaluated 3 reused 1002 ok, 1001 entries
"0" evaluated 3 reused 1002 ok, 1001 entries
"45" evaluated 3 reused 1002 ok, 1001 entries
same as a fresh parse: true
How It Works
- Every keystroke is one
Editand oneParse. While the line is incomplete,Parsereturns a*SyntaxErrorand no tree, so the editor keeps the last good tree. The first 499 settings are still reused (the parser stops at the first error and does not touch the rest); the cost is the same 3 evaluations. - When the line becomes valid (after
3), everything after it is reused again: 1,002 reuses, 3 evaluations. - The last line of the output checks the contract: the incremental tree equals a fresh parse of the same text (the comparison here is by JSON, which includes positions).
Variations and Limits
- Keep one
Documentper open file and one goroutine perDocument, with theParsershared. - Convert your editor's positions to the document's unit before
Edit. InBytesmode, a position inside a character is an error rather than a silent corruption. - If the grammar uses
#recover,Parsereturns a tree and apego.SyntaxErrorstogether: keep the tree, show the errors. - Debounce: reparse after the user pauses, not after every key, and merge the edits that arrived meanwhile.
- The kept tree shares its nodes with the document, and later parses move the nodes they reuse to their new
positions. If the editor needs the last good tree exactly as it was (for example, to map positions in the text it
was parsed from), keep
node.Clone()instead. - If something looks wrong, compare with
Parser.Parseondoc.Text(). The two must agree.
See Incremental Parsing for the Document API and
Incremental Parsing Performance for what reuse costs on other grammars.