返回 DeepSeek-Reasonix
deepseek_protocol_migration.go
根目录 / internal / config / deepseek_protocol_migration.go
1 package config
2
3 import (
4 "errors"
5 "fmt"
6 "net/url"
7 "os"
8 "slices"
9 "sort"
10 "strconv"
11 "strings"
12
13 "github.com/BurntSushi/toml"
14
15 "reasonix/internal/fileutil"
16 fileencoding "reasonix/internal/fileutil/encoding"
17 )
18
19 const deepSeekOfficialBalanceURL = "https://api.deepseek.com/user/balance"
20
21 // MigrateLegacyDeepSeekProtocolUserConfig upgrades only unmodified legacy
22 // DeepSeek provider aliases in the user-global config. It deliberately edits
23 // the original TOML in place instead of rendering Config, so comments, future
24 // fields, and unrelated provider blocks survive byte-for-byte.
25 func MigrateLegacyDeepSeekProtocolUserConfig() (bool, error) {
26 path := userConfigLoadPath()
27 if strings.TrimSpace(path) == "" {
28 return false, nil
29 }
30 return editLegacyDeepSeekProtocolFile(path, "", true)
31 }
32
33 // IsDeepSeekProtocolConfigParseError reports whether migration failed while
34 // parsing the user configuration rather than reading, locking, or writing it.
35 func IsDeepSeekProtocolConfigParseError(err error) bool {
36 var parseErr toml.ParseError
37 return errors.As(err, &parseErr)
38 }
39
40 // UpgradeDeepSeekProviderProtocol switches one official DeepSeek provider
41 // family to Anthropic Messages after an explicit user action. Passing the
42 // canonical name "deepseek" upgrades matching canonical/legacy alias blocks.
43 func UpgradeDeepSeekProviderProtocol(path, name string) (bool, error) {
44 name = strings.TrimSpace(name)
45 if name == "" {
46 return false, fmt.Errorf("upgrade DeepSeek protocol: empty provider name")
47 }
48 return editLegacyDeepSeekProtocolFile(path, name, false)
49 }
50
51 // UpgradeDeepSeekProviderProtocolUserConfig applies the explicit upgrade to
52 // the active user-global source, including a legacy config location.
53 func UpgradeDeepSeekProviderProtocolUserConfig(name string) (bool, error) {
54 return UpgradeDeepSeekProviderProtocol(userConfigLoadPath(), name)
55 }
56
57 // CanUpgradeDeepSeekProviderProtocolUserConfig reports whether the active
58 // user-global source contains a safely mappable provider in the requested
59 // DeepSeek family. Settings uses the same rewrite parser as the mutation path,
60 // so a project-only provider or an unsupported TOML shape cannot expose an
61 // action that would later edit a different file or fail unexpectedly.
62 func CanUpgradeDeepSeekProviderProtocolUserConfig(name string) bool {
63 path := userConfigLoadPath()
64 if strings.TrimSpace(path) == "" {
65 return false
66 }
67 resolved, exists, err := statConfigPath(path)
68 if err != nil || !exists {
69 return false
70 }
71 raw, err := fileencoding.ReadFileUTF8(resolved)
72 if err != nil {
73 return false
74 }
75 _, changed, err := rewriteLegacyDeepSeekProtocol(string(raw), name, false)
76 return err == nil && changed
77 }
78
79 // CanUpgradeDeepSeekProviderProtocol reports whether Settings may offer the
80 // explicit protocol upgrade. Custom transport/capability fields prevent the
81 // automatic migration but remain preserved when the user confirms this action.
82 func CanUpgradeDeepSeekProviderProtocol(p *ProviderEntry) bool {
83 if p == nil || !strings.EqualFold(strings.TrimSpace(p.Kind), "openai") ||
84 !isOfficialDeepSeekOpenAIEndpoint(p.BaseURL) ||
85 strings.TrimSpace(p.APIKeyEnv) == "" {
86 return false
87 }
88 models := p.ModelList()
89 switch strings.TrimSpace(p.Name) {
90 case "deepseek-flash":
91 return len(models) == 1 && strings.TrimSpace(models[0]) == "deepseek-v4-flash"
92 case "deepseek-pro":
93 return len(models) == 1 && strings.TrimSpace(models[0]) == "deepseek-v4-pro"
94 case "deepseek":
95 if len(models) == 0 {
96 return false
97 }
98 for _, model := range models {
99 switch strings.TrimSpace(model) {
100 case "deepseek-v4-flash", "deepseek-v4-pro":
101 default:
102 return false
103 }
104 }
105 return true
106 default:
107 return false
108 }
109 }
110
111 func editLegacyDeepSeekProtocolFile(path, target string, automatic bool) (bool, error) {
112 unlock, err := LockConfigFileEdits(path)
113 if err != nil {
114 return false, err
115 }
116 defer unlock()
117 return editLegacyDeepSeekProtocolFileLocked(path, target, automatic)
118 }
119
120 // UpgradeDeepSeekProviderProtocolLocked is the narrow edit for a caller that
121 // already owns LockUserConfigEdits, including a compare-and-save transaction.
122 func UpgradeDeepSeekProviderProtocolLocked(path, name string) (bool, error) {
123 return editLegacyDeepSeekProtocolFileLocked(path, name, false)
124 }
125
126 func (c *Config) UpgradeDeepSeekProviderProtocolLocked(path, name string) (bool, error) {
127 return editLegacyDeepSeekProtocolFileLocked(path, name, false, c.publishModelConfigBytes)
128 }
129
130 func editLegacyDeepSeekProtocolFileLocked(path, target string, automatic bool, publisher ...func(string, []byte, os.FileMode) error) (bool, error) {
131 resolved, exists, err := statConfigPath(path)
132 if err != nil || !exists {
133 return false, err
134 }
135 info, err := os.Stat(resolved)
136 if err != nil {
137 return false, err
138 }
139 rawBytes, err := os.ReadFile(resolved)
140 if err != nil {
141 return false, err
142 }
143 encoding, detected := fileencoding.Detect(rawBytes)
144 raw := fileencoding.Decode(detected, encoding)
145 next, changed, err := rewriteLegacyDeepSeekProtocol(string(raw), target, automatic)
146 if err != nil || !changed {
147 return changed, err
148 }
149 write := fileutil.AtomicWriteFileStrict
150 if len(publisher) > 0 {
151 write = publisher[0]
152 }
153 encoded, err := fileencoding.Encode(next, encoding)
154 if err != nil {
155 return false, err
156 }
157 if err := write(resolved, encoded, info.Mode().Perm()); err != nil {
158 return false, err
159 }
160 return true, nil
161 }
162
163 func rewriteLegacyDeepSeekProtocol(raw, target string, automatic bool) (string, bool, error) {
164 // Retained for compatibility callers; current startup no longer invokes
165 // the old automatic Messages migration, and v8 choices must stay untouched.
166 if automatic {
167 var header struct {
168 ConfigVersion int `toml:"config_version"`
169 }
170 if _, err := toml.Decode(raw, &header); err != nil {
171 return raw, false, err
172 }
173 if header.ConfigVersion >= deepSeekChatDefaultConfigVersion {
174 return raw, false, nil
175 }
176 }
177 return rewriteDeepSeekProtocol(raw, "anthropic", deepSeekAnthropicBaseURL, func(entry *ProviderEntry, fields map[string]any) bool {
178 if !CanUpgradeDeepSeekProviderProtocol(entry) {
179 return false
180 }
181 if automatic {
182 return isUnmodifiedLegacyDeepSeekProvider(*entry, fields)
183 }
184 return deepSeekUpgradeTargetMatches(target, entry.Name)
185 })
186 }
187
188 // Shared lexical rewrite preserves comments, unknown fields and inline tables.
189 func rewriteDeepSeekProtocol(raw, kind, baseURL string, eligible func(*ProviderEntry, map[string]any) bool) (string, bool, error) {
190 var decoded struct {
191 Providers []ProviderEntry `toml:"providers"`
192 }
193 if _, err := toml.Decode(raw, &decoded); err != nil {
194 return raw, false, err
195 }
196 var generic struct {
197 Providers []map[string]any `toml:"providers"`
198 }
199 if _, err := toml.Decode(raw, &generic); err != nil {
200 return raw, false, err
201 }
202
203 lines := strings.Split(raw, "\n")
204 blocks := providerTOMLBlocks(lines)
205 if len(blocks) == len(decoded.Providers) && len(generic.Providers) == len(decoded.Providers) {
206 changed := false
207 for i := range decoded.Providers {
208 entry := &decoded.Providers[i]
209 if !eligible(entry, generic.Providers[i]) {
210 continue
211 }
212 if err := rewriteDeepSeekProviderBlockAs(lines, blocks[i], kind, baseURL); err != nil {
213 return raw, false, err
214 }
215 changed = true
216 }
217 return strings.Join(lines, "\n"), changed, nil
218 }
219
220 inlineBlocks, err := providerTOMLInlineBlocks(raw)
221 if err != nil || len(inlineBlocks) != len(decoded.Providers) || len(generic.Providers) != len(decoded.Providers) {
222 return raw, false, fmt.Errorf("upgrade DeepSeek protocol: could not map provider tables safely")
223 }
224 replacements := make([]tomlReplacement, 0, len(decoded.Providers)*2)
225 for i := range decoded.Providers {
226 entry := &decoded.Providers[i]
227 if !eligible(entry, generic.Providers[i]) {
228 continue
229 }
230 block := inlineBlocks[i]
231 if block.kindStart < 0 || block.baseURLStart < 0 {
232 return raw, false, fmt.Errorf("upgrade DeepSeek protocol: inline provider table is missing kind or base_url")
233 }
234 replacements = append(replacements,
235 tomlReplacement{start: block.kindStart, end: block.kindEnd, value: strconv.Quote(kind)},
236 tomlReplacement{start: block.baseURLStart, end: block.baseURLEnd, value: strconv.Quote(baseURL)},
237 )
238 if kind == "openai" {
239 // Clear the standard override rather than pin the canonical URL so
240 // the derived endpoint applies and independent search stays enabled.
241 for _, span := range block.chatEndpoints {
242 replacements = append(replacements, tomlReplacement{start: span[0], end: span[1], value: strconv.Quote("")})
243 }
244 }
245 }
246 if len(replacements) == 0 {
247 return raw, false, nil
248 }
249 return applyTOMLReplacements(raw, replacements), true, nil
250 }
251
252 func isUnmodifiedLegacyDeepSeekProvider(p ProviderEntry, raw map[string]any) bool {
253 if p.Name != "deepseek-flash" && p.Name != "deepseek-pro" {
254 return false
255 }
256 if !isExactDeepSeekOpenAIEndpoint(p.BaseURL) {
257 return false
258 }
259 // Automatic migration is intentionally narrower than the explicit Settings
260 // upgrade: only the stock environment variable is unambiguous enough to
261 // change without user confirmation.
262 if strings.TrimSpace(p.APIKeyEnv) != "DEEPSEEK_API_KEY" {
263 return false
264 }
265 allowed := map[string]bool{
266 "name": true, "kind": true, "base_url": true, "model": true,
267 "api_key_env": true, "balance_url": true, "context_window": true,
268 "price": true,
269 }
270 for key := range raw {
271 if !allowed[key] {
272 return false
273 }
274 }
275 for _, required := range []string{"name", "kind", "base_url", "model", "api_key_env"} {
276 if _, ok := raw[required]; !ok {
277 return false
278 }
279 }
280 if p.BalanceURL != "" && strings.TrimRight(strings.TrimSpace(p.BalanceURL), "/") != deepSeekOfficialBalanceURL {
281 return false
282 }
283 if p.ContextWindow != 0 && p.ContextWindow != 1_000_000 {
284 return false
285 }
286 return p.Price == nil || IsKnownDeepSeekOfficialPricing(p.Model, p.Price)
287 }
288
289 func deepSeekUpgradeTargetMatches(target, providerName string) bool {
290 target = strings.TrimSpace(target)
291 providerName = strings.TrimSpace(providerName)
292 if target == providerName {
293 return true
294 }
295 if CanonicalDesktopOfficialProviderName(target) != "deepseek" {
296 return false
297 }
298 return CanonicalDesktopOfficialProviderName(providerName) == "deepseek"
299 }
300
301 func isExactDeepSeekOpenAIEndpoint(raw string) bool {
302 path, ok := deepSeekOpenAIEndpointPath(raw)
303 return ok && path == ""
304 }
305
306 func isOfficialDeepSeekOpenAIEndpoint(raw string) bool {
307 path, ok := deepSeekOpenAIEndpointPath(raw)
308 return ok && (path == "" || path == "/v1")
309 }
310
311 func deepSeekOpenAIEndpointPath(raw string) (string, bool) {
312 u, err := url.Parse(strings.TrimSpace(raw))
313 if err != nil || !strings.EqualFold(u.Scheme, "https") ||
314 !strings.EqualFold(u.Hostname(), "api.deepseek.com") || u.Port() != "" ||
315 u.User != nil || u.RawQuery != "" || u.Fragment != "" {
316 return "", false
317 }
318 return strings.TrimRight(u.EscapedPath(), "/"), true
319 }
320
321 type providerTOMLBlock struct {
322 start int
323 end int
324 }
325
326 func providerTOMLBlocks(lines []string) []providerTOMLBlock {
327 headerLines := make([]int, 0)
328 providerStarts := make([]int, 0)
329 state := tomlOutside
330 for i, line := range lines {
331 if state != tomlOutside {
332 state = advanceTOMLStringState(state, line)
333 continue
334 }
335 if tomlSectionHeader(line) != "" {
336 headerLines = append(headerLines, i)
337 if isProviderArrayTableHeader(line) {
338 providerStarts = append(providerStarts, i)
339 }
340 }
341 state = advanceTOMLStringState(tomlOutside, line)
342 }
343 out := make([]providerTOMLBlock, 0, len(providerStarts))
344 for _, start := range providerStarts {
345 end := len(lines)
346 for _, header := range headerLines {
347 if header > start {
348 end = header
349 break
350 }
351 }
352 out = append(out, providerTOMLBlock{start: start, end: end})
353 }
354 return out
355 }
356
357 type providerTOMLInlineBlock struct {
358 chatEndpoints [][2]int
359 start, end int
360 kindStart, kindEnd int
361 baseURLStart, baseURLEnd int
362 fields map[string]providerTOMLInlineField
363 segments [][2]int
364 }
365
366 type providerTOMLInlineField struct {
367 valueStart, valueEnd int
368 segment int
369 }
370
371 type tomlReplacement struct {
372 start, end int
373 value string
374 }
375
376 // providerTOMLInlineBlocks locates providers declared as an inline TOML array
377 // while preserving byte offsets so migration can edit only two scalar values.
378 // The parser is deliberately lexical: BurntSushi/toml validates the document,
379 // while this scan handles nested arrays/tables and quoted delimiters without
380 // re-rendering comments or unknown fields.
381 func providerTOMLInlineBlocks(raw string) ([]providerTOMLInlineBlock, error) {
382 arrayStart, arrayEnd, err := providerTOMLInlineArrayRange(raw)
383 if err != nil {
384 return nil, err
385 }
386 return collectProviderTOMLInlineBlocks(raw, arrayStart, arrayEnd)
387 }
388
389 func providerTOMLInlineArrayRange(raw string) (int, int, error) {
390 arrayStart, arrayEnd := -1, -1
391 section := ""
392 state := tomlOutside
393 for _, span := range tomlLineSpans(raw) {
394 if state != tomlOutside {
395 state = advanceTOMLStringState(state, span.text)
396 continue
397 }
398 if header := tomlSectionHeader(span.text); header != "" {
399 section = header
400 state = advanceTOMLStringState(tomlOutside, span.text)
401 continue
402 }
403 if section != "" {
404 state = advanceTOMLStringState(tomlOutside, span.text)
405 continue
406 }
407 line := strings.TrimRight(span.text, "\r\n")
408 nextState := advanceTOMLStringState(tomlOutside, line)
409 key, _, ok := tomlKeyValue(line)
410 if !ok || strings.Trim(key, `"'`) != "providers" {
411 state = nextState
412 continue
413 }
414 equals := strings.IndexByte(line, '=')
415 valueStart := span.start + equals + 1
416 for valueStart < len(raw) && (raw[valueStart] == ' ' || raw[valueStart] == '\t' || raw[valueStart] == '\r' || raw[valueStart] == '\n') {
417 valueStart++
418 }
419 if valueStart >= len(raw) || raw[valueStart] != '[' {
420 state = nextState
421 continue
422 }
423 valueEnd, err := scanTOMLDelimitedValue(raw, valueStart, '[', ']')
424 if err != nil {
425 return -1, -1, err
426 }
427 arrayStart, arrayEnd = valueStart, valueEnd
428 break
429 }
430 if arrayStart < 0 {
431 return -1, -1, fmt.Errorf("providers inline array not found")
432 }
433 return arrayStart, arrayEnd, nil
434 }
435
436 func collectProviderTOMLInlineBlocks(raw string, arrayStart, arrayEnd int) ([]providerTOMLInlineBlock, error) {
437 var tables []providerTOMLInlineBlock
438 stack := make([]byte, 0, 4)
439 tableStart := -1
440 var scanErr error
441 err := scanTOMLOutsideStrings(raw, arrayStart, arrayEnd+1, func(pos int, ch byte) bool {
442 if scanErr != nil {
443 return false
444 }
445 switch ch {
446 case '[', '{':
447 stack = append(stack, ch)
448 if ch == '{' && len(stack) == 2 && stack[0] == '[' {
449 tableStart = pos
450 }
451 case ']', '}':
452 if len(stack) == 0 || (ch == ']' && stack[len(stack)-1] != '[') || (ch == '}' && stack[len(stack)-1] != '{') {
453 scanErr = fmt.Errorf("invalid providers inline array nesting")
454 return false
455 }
456 if ch == '}' && len(stack) == 2 && tableStart >= 0 {
457 block, err := parseProviderTOMLInlineBlock(raw, tableStart, pos)
458 if err != nil {
459 scanErr = err
460 return false
461 }
462 tables = append(tables, block)
463 tableStart = -1
464 }
465 stack = stack[:len(stack)-1]
466 }
467 return true
468 })
469 if scanErr != nil {
470 return nil, scanErr
471 }
472 if err != nil {
473 return nil, err
474 }
475 if len(stack) != 0 || len(tables) == 0 {
476 return nil, fmt.Errorf("providers inline array contains no provider tables")
477 }
478 return tables, nil
479 }
480
481 func parseProviderTOMLInlineBlock(raw string, start, end int) (providerTOMLInlineBlock, error) {
482 block := providerTOMLInlineBlock{
483 start: start, end: end, kindStart: -1, baseURLStart: -1,
484 fields: make(map[string]providerTOMLInlineField),
485 }
486 segmentStart := start + 1
487 depth := 0
488 var segments [][2]int
489 var scanErr error
490 err := scanTOMLOutsideStrings(raw, start+1, end, func(pos int, ch byte) bool {
491 if scanErr != nil {
492 return false
493 }
494 switch ch {
495 case '[', '{':
496 depth++
497 case ']', '}':
498 depth--
499 if depth < 0 {
500 scanErr = fmt.Errorf("invalid inline provider table nesting")
501 return false
502 }
503 case ',':
504 if depth == 0 {
505 segments = append(segments, [2]int{segmentStart, pos})
506 segmentStart = pos + 1
507 }
508 }
509 return true
510 })
511 if scanErr != nil {
512 return block, scanErr
513 }
514 if err != nil {
515 return block, err
516 }
517 segments = append(segments, [2]int{segmentStart, end})
518 block.segments = append(block.segments, segments...)
519 for segmentIndex, segment := range segments {
520 start, end := trimTOMLWhitespace(raw, segment[0], segment[1])
521 if start >= end {
522 continue
523 }
524 equals, err := findTOMLAssignmentEquals(raw, start, end)
525 if err != nil {
526 return block, err
527 }
528 if equals < 0 {
529 return block, fmt.Errorf("inline provider table contains a value without a key")
530 }
531 key := strings.Trim(strings.TrimSpace(raw[start:equals]), `"'`)
532 valueStart, valueEnd := trimTOMLWhitespace(raw, equals+1, end)
533 if comment := tomlInlineCommentIndex(raw[valueStart:valueEnd]); comment >= 0 {
534 valueEnd = valueStart + comment
535 valueStart, valueEnd = trimTOMLWhitespace(raw, valueStart, valueEnd)
536 }
537 block.fields[key] = providerTOMLInlineField{valueStart: valueStart, valueEnd: valueEnd, segment: segmentIndex}
538 switch key {
539 case "request_url", "chat_url":
540 // Empty overrides are equivalent to omission and stay empty.
541 if raw[valueStart:valueEnd] != `""` && raw[valueStart:valueEnd] != `''` {
542 block.chatEndpoints = append(block.chatEndpoints, [2]int{valueStart, valueEnd})
543 }
544 case "kind":
545 block.kindStart, block.kindEnd = valueStart, valueEnd
546 case "base_url":
547 block.baseURLStart, block.baseURLEnd = valueStart, valueEnd
548 }
549 }
550 return block, nil
551 }
552
553 func scanTOMLDelimitedValue(raw string, start int, open, close byte) (int, error) {
554 depth := 0
555 end := -1
556 var scanErr error
557 err := scanTOMLOutsideStrings(raw, start, len(raw), func(pos int, ch byte) bool {
558 switch ch {
559 case open:
560 depth++
561 case close:
562 depth--
563 if depth == 0 {
564 end = pos
565 return false
566 }
567 if depth < 0 {
568 scanErr = fmt.Errorf("invalid TOML array nesting")
569 return false
570 }
571 }
572 return true
573 })
574 if scanErr != nil {
575 return -1, scanErr
576 }
577 if err != nil {
578 return -1, err
579 }
580 if end < 0 {
581 return -1, fmt.Errorf("unterminated TOML inline array")
582 }
583 return end, nil
584 }
585
586 // scanTOMLOutsideStrings visits structural bytes outside TOML strings and
587 // comments. It is used only after BurntSushi/toml has validated the document.
588 func scanTOMLOutsideStrings(raw string, start, end int, visit func(int, byte) bool) error {
589 const (
590 outside = iota
591 basic
592 literal
593 multilineBasic
594 multilineLiteral
595 )
596 state, escaped := outside, false
597 for i := start; i < end; {
598 ch := raw[i]
599 switch state {
600 case basic:
601 if escaped {
602 escaped = false
603 i++
604 continue
605 }
606 switch ch {
607 case '\\':
608 escaped = true
609 case '"':
610 state = outside
611 }
612 i++
613 case literal:
614 if ch == '\'' {
615 state = outside
616 }
617 i++
618 case multilineBasic:
619 if escaped {
620 escaped = false
621 i++
622 continue
623 }
624 if ch == '\\' {
625 escaped = true
626 i++
627 continue
628 }
629 if strings.HasPrefix(raw[i:], `"""`) {
630 state = outside
631 i += 3
632 continue
633 }
634 i++
635 case multilineLiteral:
636 if strings.HasPrefix(raw[i:], "'''") {
637 state = outside
638 i += 3
639 continue
640 }
641 i++
642 default:
643 if ch == '#' {
644 for i < end && raw[i] != '\n' {
645 i++
646 }
647 continue
648 }
649 if ch == '"' {
650 run := 1
651 for i+run < end && raw[i+run] == '"' {
652 run++
653 }
654 if run >= 3 {
655 state = multilineBasic
656 i += 3
657 } else {
658 state = basic
659 i++
660 }
661 continue
662 }
663 if ch == '\'' {
664 run := 1
665 for i+run < end && raw[i+run] == '\'' {
666 run++
667 }
668 if run >= 3 {
669 state = multilineLiteral
670 i += 3
671 } else {
672 state = literal
673 i++
674 }
675 continue
676 }
677 if visit != nil && !visit(i, ch) {
678 return nil
679 }
680 i++
681 }
682 }
683 if state != outside {
684 return fmt.Errorf("unterminated TOML string")
685 }
686 return nil
687 }
688
689 func trimTOMLWhitespace(raw string, start, end int) (int, int) {
690 for start < end && strings.ContainsRune(" \t\r\n", rune(raw[start])) {
691 start++
692 }
693 for end > start && strings.ContainsRune(" \t\r\n", rune(raw[end-1])) {
694 end--
695 }
696 return start, end
697 }
698
699 func findTOMLAssignmentEquals(raw string, start, end int) (int, error) {
700 var found = -1
701 depth := 0
702 err := scanTOMLOutsideStrings(raw, start, end, func(pos int, ch byte) bool {
703 switch ch {
704 case '[', '{':
705 depth++
706 case ']', '}':
707 depth--
708 case '=':
709 if depth == 0 {
710 found = pos
711 return false
712 }
713 }
714 return true
715 })
716 return found, err
717 }
718
719 func applyTOMLReplacements(raw string, replacements []tomlReplacement) string {
720 sort.Slice(replacements, func(i, j int) bool { return replacements[i].start < replacements[j].start })
721 for _, r := range slices.Backward(replacements) {
722 raw = raw[:r.start] + r.value + raw[r.end:]
723 }
724 return raw
725 }
726
727 func isProviderArrayTableHeader(line string) bool {
728 trimmed := strings.TrimSpace(line)
729 if comment := tomlInlineCommentIndex(trimmed); comment >= 0 {
730 trimmed = strings.TrimSpace(trimmed[:comment])
731 }
732 if !strings.HasPrefix(trimmed, "[[") || !strings.HasSuffix(trimmed, "]]") {
733 return false
734 }
735 key := strings.TrimSpace(trimmed[2 : len(trimmed)-2])
736 switch {
737 case key == "providers", key == "'providers'":
738 return true
739 case len(key) >= 2 && key[0] == '"' && key[len(key)-1] == '"':
740 decoded, err := strconv.Unquote(key)
741 return err == nil && decoded == "providers"
742 default:
743 return false
744 }
745 }
746
746 lines GO