From 02d54b56bb732f7499c8975acc555826c6aa19c8 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Fri, 19 Jun 2026 18:06:45 +0100 Subject: [PATCH] perf(parser): cache syntax errors to skip walk on cache hit extractErrors is a pure function of the parsed tree (it ignores content), so re-walking the whole tree on every cache hit wasted work on the hottest path (file_read+include_ast, ast_query, edit validation). Store []SyntaxError in CachedTree and return it directly on hits. Adds TestParseCachedErrorsMatch asserting cache-hit errors equal the cache-miss parse. --- internal/parser/parser.go | 12 +++++++---- internal/parser/parser_test.go | 37 ++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/internal/parser/parser.go b/internal/parser/parser.go index a8f76e4..0fa2959 100644 --- a/internal/parser/parser.go +++ b/internal/parser/parser.go @@ -48,10 +48,13 @@ type Registry struct { } // CachedTree stores a parsed tree with its metadata. -// Content is not stored to reduce memory usage. +// Content is not stored to reduce memory usage. Syntax errors are a pure +// function of the tree (extractErrors ignores content), so they are cached +// alongside it to avoid re-walking the whole tree on every cache hit. type CachedTree struct { Tree *sitter.Tree Language protocol.Language + Errors []SyntaxError } // ParseResult contains the result of parsing a file. @@ -202,11 +205,10 @@ func (r *Registry) Parse(ctx context.Context, filename string, content []byte) ( hash := fmt.Sprintf("%s:%016x", string(lang), xxhash.Sum64(content)) if cached, ok := r.cache.Get(hash); ok && cached.Language == lang { r.cacheHits.Add(1) - errors := extractErrors(cached.Tree.RootNode(), content) return &ParseResult{ Tree: cached.Tree, Language: lang, - Errors: errors, + Errors: cached.Errors, Content: content, }, nil } @@ -242,10 +244,12 @@ func (r *Registry) Parse(ctx context.Context, filename string, content []byte) ( // Extract syntax errors errors := extractErrors(tree.RootNode(), content) - // Cache result (LRU cache handles eviction automatically) + // Cache result (LRU cache handles eviction automatically). + // Errors are cached so cache hits skip a full tree walk. r.cache.Add(hash, &CachedTree{ Tree: tree, Language: lang, + Errors: errors, }) return &ParseResult{ diff --git a/internal/parser/parser_test.go b/internal/parser/parser_test.go index 1e0f729..98d2012 100644 --- a/internal/parser/parser_test.go +++ b/internal/parser/parser_test.go @@ -172,6 +172,43 @@ func TestParseWithSyntaxErrors(t *testing.T) { } } +// TestParseCachedErrorsMatch verifies that the syntax errors returned on a +// cache hit are identical to those computed on the initial parse. Errors are +// cached alongside the tree (they are a pure function of the tree), so the hot +// cache-hit path must not lose or alter them. +func TestParseCachedErrorsMatch(t *testing.T) { + r := NewRegistry() + defer r.Close() + + content := []byte("package main\n\nfunc main( {}\n") // missing closing paren + ctx := context.Background() + + first, err := r.Parse(ctx, "test.go", content) + if err != nil { + t.Fatalf("first parse failed: %v", err) + } + if len(first.Errors) == 0 { + t.Fatal("expected syntax errors on first parse") + } + + second, err := r.Parse(ctx, "test.go", content) + if err != nil { + t.Fatalf("cached parse failed: %v", err) + } + if second.Tree != first.Tree { + t.Fatal("expected cache hit (same tree pointer)") + } + + if len(second.Errors) != len(first.Errors) { + t.Fatalf("cached errors count = %d, want %d", len(second.Errors), len(first.Errors)) + } + for i := range first.Errors { + if second.Errors[i] != first.Errors[i] { + t.Errorf("cached error[%d] = %+v, want %+v", i, second.Errors[i], first.Errors[i]) + } + } +} + func TestIsBinary(t *testing.T) { tests := []struct { name string