返回 DeepSeek-Reasonix
opencode_go_rewrite.go
根目录 / internal / config / opencode_go_rewrite.go
1 package config
2
3 import (
4 "fmt"
5 "reflect"
6 "slices"
7 "sort"
8 "strconv"
9 "strings"
10
11 "github.com/BurntSushi/toml"
12 )
13
14 // rawTOMLSet edits only the owning assignment. Unrelated text, including unknown
15 // keys and comments, is retained. Tables and quoted/dotted keys are supported.
16 func rawTOMLSet(body string, path []string, value any) (string, error) {
17 lines := strings.Split(body, "\n")
18 var section []string
19 insert := 0
20 parentSeen := len(path) == 1
21 for i := 0; i < len(lines); i++ {
22 line := strings.TrimSpace(lines[i])
23 if header := tomlSectionHeader(line); header != "" {
24 section = rawTOMLKeyPath(strings.Trim(header, "[]"))
25 if reflect.DeepEqual(section, path[:len(path)-1]) {
26 insert, parentSeen = i+1, true
27 }
28 continue
29 }
30 if line == "" || strings.HasPrefix(line, "#") {
31 continue
32 }
33 eq, err := findTOMLAssignmentEquals(lines[i], 0, len(lines[i]))
34 if err != nil || eq < 0 {
35 continue
36 }
37 key := rawTOMLKeyPath(strings.TrimSpace(lines[i][:eq]))
38 full := append(append([]string{}, section...), key...)
39 if len(section) == 0 && len(path) > 1 && path[0] == "providers" {
40 full = append([]string{"providers"}, full...)
41 }
42 // Consume an entire multiline value, so embedded fake headers/keys
43 // cannot be mistaken for configuration syntax.
44 end := i
45 var decoded map[string]any
46 for ; end < len(lines); end++ {
47 if _, e := toml.Decode("v = "+strings.Join(append([]string{lines[i][eq+1:]}, lines[i+1:end+1]...), "\n"), &decoded); e == nil {
48 break
49 }
50 }
51 if end == len(lines) {
52 return body, fmt.Errorf("cannot locate TOML value for %s", strings.Join(full, "."))
53 }
54 if len(full) <= len(path) && reflect.DeepEqual(full, path[:len(full)]) {
55 v := value
56 if len(full) < len(path) {
57 m, ok := decoded["v"].(map[string]any)
58 if !ok {
59 i = end
60 continue
61 }
62 root := m
63 for _, k := range path[len(full) : len(path)-1] {
64 nested, ok := m[k].(map[string]any)
65 if !ok {
66 nested = map[string]any{}
67 m[k] = nested
68 }
69 m = nested
70 }
71 m[path[len(path)-1]] = value
72 v = root
73 }
74 encoded, err := rawTOMLValue(v)
75 if err != nil {
76 return body, err
77 }
78 replacement := replaceTOMLScalarAssignment(lines[i], encoded)
79 // Retain comments inside an edited multiline array as adjacent
80 // comments; the rest of the document remains byte-for-byte intact.
81 var comments []string
82 for n := i + 1; n <= end; n++ {
83 if at := tomlInlineCommentIndex(lines[n]); at >= 0 {
84 comments = append(comments, lines[n][at:])
85 }
86 }
87 out := append([]string{}, lines[:i]...)
88 out = append(out, comments...)
89 out = append(out, replacement)
90 out = append(out, lines[end+1:]...)
91 return strings.Join(out, "\n"), nil
92 }
93 i = end
94 }
95 encoded, err := rawTOMLValue(value)
96 if err != nil {
97 return body, err
98 }
99 assignment := strconv.Quote(path[len(path)-1]) + " = " + encoded
100 if parentSeen {
101 lines = append(lines[:insert], append([]string{assignment}, lines[insert:]...)...)
102 return strings.Join(lines, "\n"), nil
103 }
104 keys := make([]string, len(path)-1)
105 for i, k := range path[:len(path)-1] {
106 keys[i] = rawTOMLSectionKey(k)
107 }
108 return strings.TrimRight(body, "\n") + "\n[" + strings.Join(keys, ".") + "]\n" + assignment + "\n", nil
109 }
110
111 // rawTOMLSectionKey keeps ordinary section names in the renderer's canonical
112 // form. Older incremental writers identify nested sections by bare parents.
113 func rawTOMLSectionKey(key string) string {
114 if key != "" && strings.IndexFunc(key, func(r rune) bool {
115 return !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-')
116 }) == -1 {
117 return key
118 }
119 return strconv.Quote(key)
120 }
121
122 func rawTOMLKeyPath(s string) []string {
123 // Let the validated TOML parser handle escapes and quoted dots.
124 var m map[string]any
125 if _, err := toml.Decode(s+" = 0", &m); err != nil {
126 return nil
127 }
128 var out []string
129 for len(m) == 1 {
130 for k, v := range m {
131 out = append(out, k)
132 m, _ = v.(map[string]any)
133 }
134 }
135 return out
136 }
137
138 func rawTOMLValue(v any) (string, error) {
139 if v == nil {
140 return "", fmt.Errorf("cannot encode absent TOML value")
141 }
142 rv := reflect.ValueOf(v)
143 if rv.Kind() == reflect.Pointer {
144 return rawTOMLValue(rv.Elem().Interface())
145 }
146 switch rv.Kind() {
147 case reflect.String:
148 return strconv.Quote(rv.String()), nil
149 case reflect.Bool:
150 return strconv.FormatBool(rv.Bool()), nil
151 case reflect.Int, reflect.Int64, reflect.Int32:
152 return strconv.FormatInt(rv.Int(), 10), nil
153 case reflect.Float64, reflect.Float32:
154 encoded := strconv.FormatFloat(rv.Float(), 'g', -1, 64)
155 if strings.HasSuffix(encoded, "Inf") || encoded == "NaN" {
156 return strings.ToLower(encoded), nil
157 }
158 // Retain TOML's float type when rewriting an inline table containing
159 // a whole-number rate, including fields unrelated to the migration.
160 if !strings.ContainsAny(encoded, ".eE") {
161 encoded += ".0"
162 }
163 return encoded, nil
164 case reflect.Slice, reflect.Array:
165 var parts []string
166 for i := range rv.Len() {
167 s, err := rawTOMLValue(rv.Index(i).Interface())
168 if err != nil {
169 return "", err
170 }
171 parts = append(parts, s)
172 }
173 return "[" + strings.Join(parts, ", ") + "]", nil
174 case reflect.Map:
175 var keys []string
176 for _, k := range rv.MapKeys() {
177 keys = append(keys, k.String())
178 }
179 sort.Strings(keys)
180 var parts []string
181 for _, k := range keys {
182 s, err := rawTOMLValue(rv.MapIndex(reflect.ValueOf(k)).Interface())
183 if err != nil {
184 return "", err
185 }
186 parts = append(parts, strconv.Quote(k)+" = "+s)
187 }
188 return "{ " + strings.Join(parts, ", ") + " }", nil
189 }
190 return "", fmt.Errorf("unsupported TOML edit value %T", v)
191 }
192
193 func rewriteOpenCodeGoConfig(body string, before, after *Config, additions []openCodeGoGroup) (string, error) {
194 if reflect.DeepEqual(before.Providers, after.Providers) && reflect.DeepEqual(before.Agent, after.Agent) && reflect.DeepEqual(before.Bot, after.Bot) && reflect.DeepEqual(before.Desktop, after.Desktop) && before.DefaultModel == after.DefaultModel && len(additions) == 0 {
195 return rawTOMLSet(body, []string{"config_version"}, openCodeGoUpgradeVersion)
196 }
197 lines := strings.Split(body, "\n")
198 blocks := providerTOMLBlocks(lines)
199 if len(blocks) != len(before.Providers) {
200 expanded, err := expandOpenCodeGoInlineProviders(body)
201 if err != nil {
202 return body, err
203 }
204 return rewriteOpenCodeGoConfig(expanded, before, after, additions)
205 }
206 // Extend each provider span through its nested tables only.
207 for i := range blocks {
208 for blocks[i].end < len(lines) {
209 h := tomlSectionHeader(lines[blocks[i].end])
210 p := rawTOMLKeyPath(strings.Trim(h, "[]"))
211 if h != "" && (len(p) < 2 || p[0] != "providers") {
212 break
213 }
214 blocks[i].end++
215 }
216 }
217 var originals []string
218 for _, b := range blocks {
219 originals = append(originals, strings.Join(lines[b.start+1:b.end], "\n"))
220 }
221 // after.Providers holds each split group right behind its source, so the
222 // original index i maps to i plus the groups split from earlier sources.
223 afterIndex := make([]int, len(blocks))
224 for i := range blocks {
225 if i > 0 {
226 afterIndex[i] = afterIndex[i-1] + 1
227 for _, addition := range additions {
228 if addition.source == i-1 {
229 afterIndex[i]++
230 }
231 }
232 }
233 }
234 for i := range slices.Backward(blocks) {
235 next, err := patchOpenCodeGoProvider(originals[i], before.Providers[i], after.Providers[afterIndex[i]])
236 if err != nil {
237 return body, err
238 }
239 var siblings []string
240 for _, addition := range additions {
241 if addition.source != i {
242 continue
243 }
244 raw, err := patchOpenCodeGoProvider(originals[i], before.Providers[i], addition.entry)
245 if err != nil {
246 return body, err
247 }
248 siblings = append(siblings, "", "[[providers]]")
249 siblings = append(siblings, strings.Split(strings.TrimRight(raw, "\n"), "\n")...)
250 }
251 b := blocks[i]
252 replacement := strings.Split(next, "\n")
253 if len(siblings) > 0 {
254 replacement = append(append(trimTrailingBlankLines(replacement), siblings...), "")
255 }
256 lines = append(lines[:b.start+1], append(replacement, lines[b.end:]...)...)
257 }
258 body = strings.Join(lines, "\n")
259 return rewriteOpenCodeGoReferences(body, before, after)
260 }
261
262 func trimTrailingBlankLines(lines []string) []string {
263 for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" {
264 lines = lines[:len(lines)-1]
265 }
266 return lines
267 }
268
269 // verifyOpenCodeGoRewrite proves the lexical edit still describes the planned
270 // configuration before any byte reaches disk. Every rewrite path ends here.
271 func verifyOpenCodeGoRewrite(body string, after *Config) error {
272 var check Config
273 if _, err := toml.Decode(body, &check); err != nil {
274 return fmt.Errorf("migration readback: %w", err)
275 }
276 if check.ConfigVersion != openCodeGoUpgradeVersion {
277 return fmt.Errorf("migration readback: config_version %d", check.ConfigVersion)
278 }
279 if check.DefaultModel != after.DefaultModel {
280 return fmt.Errorf("migration readback: default_model %q", check.DefaultModel)
281 }
282 if len(check.Providers) != len(after.Providers) {
283 return fmt.Errorf("provider count changed during lexical rewrite")
284 }
285 for i, p := range check.Providers {
286 want := after.Providers[i]
287 if p.Name != want.Name || p.Kind != want.Kind || p.BaseURL != want.BaseURL || !reflect.DeepEqual(p.ModelList(), want.ModelList()) {
288 return fmt.Errorf("provider %q failed migration readback", want.Name)
289 }
290 }
291 return nil
292 }
293
294 // Expand the uncommon inline-array form lexically. Only structural separators
295 // change; string contents, unknown fields and comments remain in the document.
296 func expandOpenCodeGoInlineProviders(body string) (string, error) {
297 start, end, err := providerTOMLInlineArrayRange(body)
298 if err != nil {
299 return body, err
300 }
301 blocks, err := providerTOMLInlineBlocks(body)
302 if err != nil {
303 return body, fmt.Errorf("cannot safely map inline providers: %w", err)
304 }
305 if len(blocks) == 0 {
306 return body, fmt.Errorf("cannot safely map inline providers: no provider blocks")
307 }
308 assignment := strings.LastIndex(body[:start], "\n") + 1
309 var tables strings.Builder
310 outside := body[start+1 : end]
311 for _, b := range slices.Backward(blocks) {
312 a, z := b.start-start-1, b.end-start
313 outside = outside[:a] + outside[z:]
314 }
315 var comments []string
316 for line := range strings.SplitSeq(outside, "\n") {
317 if at := tomlInlineCommentIndex(line); at >= 0 {
318 comments = append(comments, line[at:])
319 }
320 }
321 for _, b := range blocks {
322 chunk := []byte(body[b.start+1 : b.end])
323 depth := 0
324 if err := scanTOMLOutsideStrings(string(chunk), 0, len(chunk), func(pos int, ch byte) bool {
325 switch ch {
326 case '[', '{':
327 depth++
328 case ']', '}':
329 depth--
330 case ',':
331 if depth == 0 {
332 chunk[pos] = '\n'
333 }
334 }
335 return true
336 }); err != nil {
337 return body, err
338 }
339 tables.WriteString("\n[[providers]]\n" + string(chunk) + "\n")
340 }
341 return body[:assignment] + strings.Join(comments, "\n") + body[end+1:] + tables.String(), nil
342 }
343
344 func patchOpenCodeGoProvider(raw string, old, next ProviderEntry) (string, error) {
345 ov, nv := reflect.ValueOf(old), reflect.ValueOf(next)
346 for _, field := range []string{"Name", "Kind", "BaseURL", "RequestURL", "ChatURL", "Models", "Model", "Default", "PresetID", "PresetVersion", "ResponsesMode", "Effort"} {
347 a, b := ov.FieldByName(field).Interface(), nv.FieldByName(field).Interface()
348 if reflect.DeepEqual(a, b) {
349 continue
350 }
351 f, _ := ov.Type().FieldByName(field)
352 var err error
353 raw, err = rawTOMLSet(raw, []string{strings.Split(f.Tag.Get("toml"), ",")[0]}, b)
354 if err != nil {
355 return raw, err
356 }
357 }
358 for model, o := range next.ModelOverrides {
359 previous := old.ModelOverrides[model]
360 for _, pair := range []struct {
361 key string
362 a, b any
363 }{{"reasoning_protocol", previous.ReasoningProtocol, o.ReasoningProtocol}, {"supported_efforts", previous.SupportedEfforts, o.SupportedEfforts}, {"default_effort", previous.DefaultEffort, o.DefaultEffort}} {
364 if reflect.DeepEqual(pair.a, pair.b) {
365 continue
366 }
367 var err error
368 raw, err = rawTOMLSet(raw, []string{"providers", "model_overrides", model, pair.key}, pair.b)
369 if err != nil {
370 return raw, err
371 }
372 }
373 }
374 return raw, nil
375 }
376
377 func rewriteOpenCodeGoReferences(body string, before, after *Config) (string, error) {
378 // Update only reference fields whose resolved migration changed them.
379 var err error
380 for _, pair := range []struct {
381 path []string
382 a, b any
383 }{
384 {[]string{"config_version"}, before.ConfigVersion, openCodeGoUpgradeVersion},
385 {[]string{"default_model"}, before.DefaultModel, after.DefaultModel},
386 {[]string{"desktop", "provider_access"}, before.Desktop.ProviderAccess, after.Desktop.ProviderAccess},
387 {[]string{"agent", "planner_model"}, before.Agent.PlannerModel, after.Agent.PlannerModel},
388 {[]string{"agent", "vision_model"}, before.Agent.VisionModel, after.Agent.VisionModel},
389 {[]string{"agent", "guardian_model"}, before.Agent.GuardianModel, after.Agent.GuardianModel},
390 {[]string{"agent", "recovery_model"}, before.Agent.RecoveryModel, after.Agent.RecoveryModel},
391 {[]string{"agent", "subagent_model"}, before.Agent.SubagentModel, after.Agent.SubagentModel},
392 {[]string{"agent", "web_search_model"}, before.Agent.WebSearchModel, after.Agent.WebSearchModel},
393 {[]string{"bot", "model"}, before.Bot.Model, after.Bot.Model},
394 } {
395 if reflect.DeepEqual(pair.a, pair.b) {
396 continue
397 }
398 body, err = rawTOMLSet(body, pair.path, pair.b)
399 if err != nil {
400 return body, err
401 }
402 }
403 for name, ref := range after.Agent.SubagentModels {
404 if before.Agent.SubagentModels[name] != ref {
405 body, err = rawTOMLSet(body, []string{"agent", "subagent_models", name}, ref)
406 if err != nil {
407 return body, err
408 }
409 }
410 }
411 // Bot array tables need positional edits, never a global string replace.
412 botIndex, inBotConnection := -1, false
413 lines := strings.Split(body, "\n")
414 for i, line := range lines {
415 if tomlSectionHeader(line) == "bot.connections" && strings.HasPrefix(strings.TrimSpace(line), "[[") {
416 botIndex++
417 inBotConnection = true
418 continue
419 }
420 if inBotConnection && botIndex >= 0 && botIndex < len(after.Bot.Connections) && isTOMLKeyAssignment(line, "model") && before.Bot.Connections[botIndex].Model != after.Bot.Connections[botIndex].Model {
421 lines[i] = replaceTOMLStringAssignment(line, after.Bot.Connections[botIndex].Model)
422 }
423 if h := tomlSectionHeader(line); h != "" {
424 inBotConnection = false
425 }
426 }
427 body = strings.Join(lines, "\n")
428 return body, nil
429 }
430
430 lines GO