From f4cc014b7d35499c5da2369824da83e4f7325a1b Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Sun, 11 Jan 2026 15:01:31 +0100 Subject: [PATCH] Better fuzzy json parsing The initial method we used was too simplistic and would make the arguments flicker because we would succeede parsing the json, but later parsing would fail and so on. This change makes the try parse more robust. Signed-off-by: Djordje Lukic --- pkg/tui/components/toolcommon/common.go | 58 +++++++++++- pkg/tui/components/toolcommon/common_test.go | 98 ++++++++++++++++++-- 2 files changed, 141 insertions(+), 15 deletions(-) diff --git a/pkg/tui/components/toolcommon/common.go b/pkg/tui/components/toolcommon/common.go index 897f08e0f..243fa35b5 100644 --- a/pkg/tui/components/toolcommon/common.go +++ b/pkg/tui/components/toolcommon/common.go @@ -23,7 +23,7 @@ func ParseArgs[T any](args string) (T, error) { return result, nil } - for _, fixed := range tryFixPartialJSON(args) { + if fixed, ok := tryFixPartialJSON(args); ok { if partialErr := json.Unmarshal([]byte(fixed), &result); partialErr == nil { return result, nil } @@ -32,11 +32,59 @@ func ParseArgs[T any](args string) (T, error) { return result, err } -func tryFixPartialJSON(s string) []string { - if s == "" { - return nil +// tryFixPartialJSON attempts to complete a partial JSON object by closing +// any unclosed strings, arrays, and objects. Returns the fixed JSON and +// true if a fix was attempted, or the original string and false if input +// is empty or not a valid JSON object start. +func tryFixPartialJSON(s string) (string, bool) { + if s == "" || s[0] != '{' { + return s, false } - return []string{s + "\"}", s + "}"} + + var result strings.Builder + result.WriteString(s) + + inString := false + escaped := false + var stack []byte + + for _, r := range s { + if escaped { + escaped = false + continue + } + if r == '\\' && inString { + escaped = true + continue + } + if r == '"' { + inString = !inString + continue + } + if inString { + continue + } + switch r { + case '{': + stack = append(stack, '}') + case '[': + stack = append(stack, ']') + case '}', ']': + if len(stack) > 0 { + stack = stack[:len(stack)-1] + } + } + } + + if inString { + result.WriteByte('"') + } + + for i := len(stack) - 1; i >= 0; i-- { + result.WriteByte(stack[i]) + } + + return result.String(), true } // ExtractField creates an argument extractor function that parses JSON and extracts a field. diff --git a/pkg/tui/components/toolcommon/common_test.go b/pkg/tui/components/toolcommon/common_test.go index 2e30ac2cf..61d2128c2 100644 --- a/pkg/tui/components/toolcommon/common_test.go +++ b/pkg/tui/components/toolcommon/common_test.go @@ -9,25 +9,89 @@ import ( func TestTryFixPartialJSON(t *testing.T) { tests := []struct { - name string - input string - expected []string + name string + input string + expected string + shouldFix bool }{ { - name: "empty string", - input: "", - expected: nil, + name: "empty string", + input: "", + expected: "", + shouldFix: false, + }, + { + name: "not json object", + input: "hello", + expected: "hello", + shouldFix: false, + }, + { + name: "just opening brace", + input: `{`, + expected: `{}`, + shouldFix: true, + }, + { + name: "partial key", + input: `{"path`, + expected: `{"path"}`, + shouldFix: true, + }, + { + name: "key with colon", + input: `{"path":`, + expected: `{"path":}`, + shouldFix: true, + }, + { + name: "incomplete string value", + input: `{"path": "/tmp/fi`, + expected: `{"path": "/tmp/fi"}`, + shouldFix: true, + }, + { + name: "complete string missing brace", + input: `{"path": "/tmp/file"`, + expected: `{"path": "/tmp/file"}`, + shouldFix: true, }, { - name: "partial JSON", - input: `{"key": "val`, - expected: []string{`{"key": "val"}`, `{"key": "val}`}, + name: "trailing comma", + input: `{"path": "/tmp/file",`, + expected: `{"path": "/tmp/file",}`, + shouldFix: true, + }, + { + name: "nested object incomplete", + input: `{"outer": {"inner": "val`, + expected: `{"outer": {"inner": "val"}}`, + shouldFix: true, + }, + { + name: "array incomplete", + input: `{"paths": ["/tmp/a", "/tmp/b`, + expected: `{"paths": ["/tmp/a", "/tmp/b"]}`, + shouldFix: true, + }, + { + name: "escaped quote in string", + input: `{"msg": "hello \"world`, + expected: `{"msg": "hello \"world"}`, + shouldFix: true, + }, + { + name: "complete json", + input: `{"path": "/tmp/file"}`, + expected: `{"path": "/tmp/file"}`, + shouldFix: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := tryFixPartialJSON(tt.input) + result, ok := tryFixPartialJSON(tt.input) + assert.Equal(t, tt.shouldFix, ok) assert.Equal(t, tt.expected, result) }) } @@ -88,6 +152,20 @@ func TestParsePartialArgs(t *testing.T) { wantCmd: "", wantErr: false, }, + { + name: "nested object in progress", + input: `{"path": "/tmp", "nested": {"key": "val`, + wantPath: "/tmp", + wantCmd: "", + wantErr: false, + }, + { + name: "array value in progress", + input: `{"path": "/tmp", "items": ["a", "b`, + wantPath: "/tmp", + wantCmd: "", + wantErr: false, + }, } for _, tt := range tests {