From e53ad2bbd3af79f2a93532c8f8edab0c45fd946d Mon Sep 17 00:00:00 2001 From: Martin Yankovs Date: Mon, 27 Jul 2026 12:29:31 +0300 Subject: [PATCH] fix(mql): terminate parse loop on quoted value as final token Parser.Parse looped forever on inputs like k:"v" where a quoted value is the final token. In parseValueFromPos the scanner-advance loop advances until the offset passes the closing quote, but when the quote is the last character the scanner is already at EOF and next() no longer advances the offset, so the loop condition stays true indefinitely. Guard the loop on forward progress: break when next() fails to advance the scanner offset. Add a regression test asserting termination via a timeout. Fixes #221 Co-Authored-By: Claude Opus 4.8 (1M context) --- mql/parser.go | 9 ++++++- mql/parser_test.go | 59 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 mql/parser_test.go diff --git a/mql/parser.go b/mql/parser.go index 7b0249d..b10a889 100644 --- a/mql/parser.go +++ b/mql/parser.go @@ -178,9 +178,16 @@ func (p *Parser) parseValueFromPos(startPos int) string { } if endIdx < len(p.input) { val := p.input[startIdx+1 : endIdx] - // Advance scanner past the quoted string + // Advance scanner past the quoted string. When the closing quote is + // the final character, the scanner is already at EOF and next() no + // longer advances its offset, so guard on forward progress to avoid + // looping forever (issue #221). for p.s.Pos().Offset-1 < endIdx+1 { + prev := p.s.Pos().Offset p.next() + if p.s.Pos().Offset == prev { + break // scanner reached EOF, no further progress possible + } } return val } diff --git a/mql/parser_test.go b/mql/parser_test.go new file mode 100644 index 0000000..2101e90 --- /dev/null +++ b/mql/parser_test.go @@ -0,0 +1,59 @@ +package mql + +import ( + "testing" + "time" +) + +// TestParseQuotedValueAsFinalToken guards against a regression where a quoted +// value appearing as the final token of the input caused Parser.Parse to spin +// forever in parseValueFromPos: at EOF the scanner stops advancing its offset, +// so the scanner-advance loop's condition never becomes false (issue #221). +// +// Each case is parsed in a goroutine so the test fails via timeout instead of +// hanging the whole suite if the loop ever regresses. +func TestParseQuotedValueAsFinalToken(t *testing.T) { + tests := []struct { + name string + input string + key string + value string + }{ + {"single-char quoted final value", `k:"v"`, "k", "v"}, + {"word quoted final value", `name:"alice"`, "name", "alice"}, + {"quoted final value with space", `k:"hello world"`, "k", "hello world"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + type parseResult struct { + expr Expr + err error + } + done := make(chan parseResult, 1) + go func() { + expr, err := NewParser(tt.input).Parse() + done <- parseResult{expr, err} + }() + + select { + case r := <-done: + if r.err != nil { + t.Fatalf("Parse(%q) returned error: %v", tt.input, r.err) + } + term, ok := r.expr.(*TermExpr) + if !ok { + t.Fatalf("Parse(%q) = %T, want *TermExpr", tt.input, r.expr) + } + if term.Key != tt.key { + t.Errorf("Parse(%q) key = %q, want %q", tt.input, term.Key, tt.key) + } + if term.Value != tt.value { + t.Errorf("Parse(%q) value = %v, want %q", tt.input, term.Value, tt.value) + } + case <-time.After(2 * time.Second): + t.Fatalf("Parse(%q) did not terminate within 2s (infinite loop)", tt.input) + } + }) + } +}