-
Notifications
You must be signed in to change notification settings - Fork 21
Expand file tree
/
Copy pathnormalize.go
More file actions
227 lines (198 loc) · 5.75 KB
/
Copy pathnormalize.go
File metadata and controls
227 lines (198 loc) · 5.75 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
package jsonrepair
import (
"strings"
"unicode/utf8"
)
// normalizeInput preprocesses the input string to handle common variations
// found in LLM output, especially from Chinese/multilingual models:
//
// - Full-width structural chars ({}[]:,) → ASCII equivalents
// - Code fences (```json ... ```) stripped from start/end
// - Line comments (// ..., # ...) and block comments (/* ... */)
//
// NOTE: Quote variants (curly/typographic quotes) are NOT normalized here
// because blanket replacement would break string literals that contain
// literal quote characters (e.g. URLs with embedded quotes). Instead,
// the parser itself recognizes curly/typographic quotes as string
// delimiters on the fly.
func normalizeInput(src string) string {
// Step 1: Normalize full-width structural characters
src = normalizePunctuation(src)
// Step 2: Strip code fences
src = stripCodeFences(src)
// Step 3: Strip comments
src = stripComments(src)
return src
}
// normalizePunctuation replaces full-width punctuation with ASCII equivalents.
// Does NOT touch quote characters — those are handled by the parser.
func normalizePunctuation(s string) string {
var sb strings.Builder
sb.Grow(len(s))
i := 0
for i < len(s) {
r, size := utf8.DecodeRuneInString(s[i:])
switch r {
case '\uff5b': // { → {
sb.WriteByte('{')
case '\uff5d': // } → }
sb.WriteByte('}')
case '\uff3b': // [ → [
sb.WriteByte('[')
case '\uff3d': // ] → ]
sb.WriteByte(']')
case '\uff1a': // : → :
sb.WriteByte(':')
case '\uff0c': // , → ,
sb.WriteByte(',')
case '\uff1b': // ; → ;
sb.WriteByte(';')
default:
sb.WriteString(s[i : i+size])
}
i += size
}
return sb.String()
}
// isQuoteByte returns true if the byte is an ASCII quote character.
func isQuoteByte(c byte) bool {
return c == '"' || c == '\''
}
// isSmartDoubleQuote returns true if the rune is a typographic/curly double quote
// or full-width quotation mark.
func isSmartDoubleQuote(r rune) bool {
return r == '\u201c' || // " LEFT DOUBLE QUOTATION MARK
r == '\u201d' || // " RIGHT DOUBLE QUOTATION MARK
r == '\u201e' || // „ DOUBLE LOW-9 QUOTATION MARK
r == '\uff02' // " FULLWIDTH QUOTATION MARK
}
// isSmartSingleQuote returns true if the rune is a typographic/curly single quote
// or full-width apostrophe.
func isSmartSingleQuote(r rune) bool {
return r == '\u2018' || // ' LEFT SINGLE QUOTATION MARK
r == '\u2019' || // ' RIGHT SINGLE QUOTATION MARK
r == '\uff07' // ' FULLWIDTH APOSTROPHE
}
// asciiQuoteForSmart maps a smart quote rune to its ASCII equivalent byte.
// Returns 0 if the rune is not a smart quote.
func asciiQuoteForSmart(r rune) byte {
if isSmartDoubleQuote(r) {
return '"'
}
if isSmartSingleQuote(r) {
return '\''
}
return 0
}
// stripCodeFences removes ```json ... ``` wrappers that LLMs commonly
// wrap their JSON output in. Handles both prefix and suffix fences.
func stripCodeFences(s string) string {
s = strings.TrimSpace(s)
// Strip leading fence: ```json, ```, ```JSON, etc.
for _, prefix := range []string{"```json", "```JSON", "```"} {
if strings.HasPrefix(s, prefix) {
s = s[len(prefix):]
break
}
}
// Strip trailing fence
if idx := strings.LastIndex(s, "```"); idx >= 0 {
s = s[:idx]
}
return strings.TrimSpace(s)
}
// stripComments removes C-style and hash comments from JSON-like input.
// Preserves content inside string literals.
// Uses a heuristic: a quote is only treated as a string delimiter if
// followed by a structural character (, } ] :), a space then structural,
// or another matching quote (for empty strings / doubled quotes).
// This avoids breaking strings with unescaped quotes inside (Issue #18).
func stripComments(s string) string {
var sb strings.Builder
sb.Grow(len(s))
i := 0
inString := false
var stringDelim byte
for i < len(s) {
c := s[i]
// Inside a string — copy verbatim, handle escapes
if inString {
sb.WriteByte(c)
if c == '\\' && i+1 < len(s) {
i++
sb.WriteByte(s[i])
} else if c == stringDelim {
// Lookahead: only end string if followed by structural or matching quote
j := i + 1
for j < len(s) && s[j] == ' ' {
j++
}
if j >= len(s) || s[j] == ',' || s[j] == '}' || s[j] == ']' ||
s[j] == ':' || s[j] == stringDelim {
inString = false
}
}
i++
continue
}
// Start of string
if c == '"' || c == '\'' {
inString = true
stringDelim = c
sb.WriteByte(c)
i++
continue
}
// Line comment: //
if c == '/' && i+1 < len(s) && s[i+1] == '/' {
i += 2
for i < len(s) && s[i] != '\n' && s[i] != '\r' {
i++
}
continue
}
// Block comment: /* ... */
if c == '/' && i+1 < len(s) && s[i+1] == '*' {
i += 2
for i < len(s)-1 {
if s[i] == '*' && s[i+1] == '/' {
i += 2
break
}
i++
}
continue
}
// Hash comment: # ...
if c == '#' {
for i < len(s) && s[i] != '\n' && s[i] != '\r' {
i++
}
continue
}
sb.WriteByte(c)
i++
}
return sb.String()
}
// getSmartQuoteByteAt checks if the byte at position index+offset in the
// container string is the start of a smart/typographic quote (UTF-8 multi-byte).
// If so, returns the ASCII equivalent byte and true. Otherwise returns 0, false.
func getSmartQuoteByteAt(container string, index int, offset int) (byte, bool) {
pos := index + offset
if pos < 0 || pos >= len(container) {
return 0, false
}
r, _ := utf8.DecodeRuneInString(container[pos:])
ascii := asciiQuoteForSmart(r)
if ascii != 0 {
return ascii, true
}
return 0, false
}
// isSmartQuoteAt returns true if the byte at position index+offset is
// the start of a smart/typographic quote.
func isSmartQuoteAt(container string, index int, offset int) bool {
_, ok := getSmartQuoteByteAt(container, index, offset)
return ok
}