返回 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 if err := write(resolved, fileencoding.Encode(next, encoding), info.Mode().Perm()); err != nil {
154 return false, err
155 }
156 return true, nil
157 }
158
159 func rewriteLegacyDeepSeekProtocol(raw, target string, automatic bool) (string, bool, error) {
160 // Retained for compatibility callers; current startup no longer invokes
161 // the old automatic Messages migration, and v8 choices must stay untouched.
162 if automatic {
163 var header struct {
164 ConfigVersion int `toml:"config_version"`
165 }
166 if _, err := toml.Decode(raw, &header); err != nil {
167 return raw, false, err
168 }
169 if header.ConfigVersion >= deepSeekChatDefaultConfigVersion {
170 return raw, false, nil
171 }
172 }
173 return rewriteDeepSeekProtocol(raw, "anthropic", deepSeekAnthropicBaseURL, func(entry *ProviderEntry, fields map[string]any) bool {
174 if !CanUpgradeDeepSeekProviderProtocol(entry) {
175 return false
176 }
177 if automatic {
178 return isUnmodifiedLegacyDeepSeekProvider(*entry, fields)
179 }
180 return deepSeekUpgradeTargetMatches(target, entry.Name)
181 })
182 }
183
184 // Shared lexical rewrite preserves comments, unknown fields and inline tables.
185 func rewriteDeepSeekProtocol(raw, kind, baseURL string, eligible func(*ProviderEntry, map[string]any) bool) (string, bool, error) {
186 var decoded struct {
187 Providers []ProviderEntry `toml:"providers"`
188 }
189 if _, err := toml.Decode(raw, &decoded); err != nil {
190 return raw, false, err
191 }
192 var generic struct {
193 Providers []map[string]any `toml:"providers"`
194 }
195 if _, err := toml.Decode(raw, &generic); err != nil {
196 return raw, false, err
197 }
198
199 lines := strings.Split(raw, "\n")
200 blocks := providerTOMLBlocks(lines)
201 if len(blocks) == len(decoded.Providers) && len(generic.Providers) == len(decoded.Providers) {
202 changed := false
203 for i := range decoded.Providers {
204 entry := &decoded.Providers[i]
205 if !eligible(entry, generic.Providers[i]) {
206 continue
207 }
208 if err := rewriteDeepSeekProviderBlockAs(lines, blocks[i], kind, baseURL); err != nil {
209 return raw, false, err
210 }
211 changed = true
212 }
213 return strings.Join(lines, "\n"), changed, nil
214 }
215
216 inlineBlocks, err := providerTOMLInlineBlocks(raw)
217 if err != nil || len(inlineBlocks) != len(decoded.Providers) || len(generic.Providers) != len(decoded.Providers) {
218 return raw, false, fmt.Errorf("upgrade DeepSeek protocol: could not map provider tables safely")
219 }
220 replacements := make([]tomlReplacement, 0, len(decoded.Providers)*2)
221 for i := range decoded.Providers {
222 entry := &decoded.Providers[i]
223 if !eligible(entry, generic.Providers[i]) {
224 continue
225 }
226 block := inlineBlocks[i]
227 if block.kindStart < 0 || block.baseURLStart < 0 {
228 return raw, false, fmt.Errorf("upgrade DeepSeek protocol: inline provider table is missing kind or base_url")
229 }
230 replacements = append(replacements,
231 tomlReplacement{start: block.kindStart, end: block.kindEnd, value: strconv.Quote(kind)},
232 tomlReplacement{start: block.baseURLStart, end: block.baseURLEnd, value: strconv.Quote(baseURL)},
233 )
234 if kind == "openai" {
235 // Clear the standard override rather than pin the canonical URL so
236 // the derived endpoint applies and independent search stays enabled.
237 for _, span := range block.chatEndpoints {
238 replacements = append(replacements, tomlReplacement{start: span[0], end: span[1], value: strconv.Quote("")})
239 }
240 }
241 }
242 if len(replacements) == 0 {
243 return raw, false, nil
244 }
245 return applyTOMLReplacements(raw, replacements), true, nil
246 }
247
248 func isUnmodifiedLegacyDeepSeekProvider(p ProviderEntry, raw map[string]any) bool {
249 if p.Name != "deepseek-flash" && p.Name != "deepseek-pro" {
250 return false
251 }
252 if !isExactDeepSeekOpenAIEndpoint(p.BaseURL) {
253 return false
254 }
255 // Automatic migration is intentionally narrower than the explicit Settings
256 // upgrade: only the stock environment variable is unambiguous enough to
257 // change without user confirmation.
258 if strings.TrimSpace(p.APIKeyEnv) != "DEEPSEEK_API_KEY" {
259 return false
260 }
261 allowed := map[string]bool{
262 "name": true, "kind": true, "base_url": true, "model": true,
263 "api_key_env": true, "balance_url": true, "context_window": true,
264 "price": true,
265 }
266 for key := range raw {
267 if !allowed[key] {
268 return false
269 }
270 }
271 for _, required := range []string{"name", "kind", "base_url", "model", "api_key_env"} {
272 if _, ok := raw[required]; !ok {
273 return false
274 }
275 }
276 if p.BalanceURL != "" && strings.TrimRight(strings.TrimSpace(p.BalanceURL), "/") != deepSeekOfficialBalanceURL {
277 return false
278 }
279 if p.ContextWindow != 0 && p.ContextWindow != 1_000_000 {
280 return false
281 }
282 return p.Price == nil || IsKnownDeepSeekOfficialPricing(p.Model, p.Price)
283 }
284
285 func deepSeekUpgradeTargetMatches(target, providerName string) bool {
286 target = strings.TrimSpace(target)
287 providerName = strings.TrimSpace(providerName)
288 if target == providerName {
289 return true
290 }
291 if CanonicalDesktopOfficialProviderName(target) != "deepseek" {
292 return false
293 }
294 return CanonicalDesktopOfficialProviderName(providerName) == "deepseek"
295 }
296
297 func isExactDeepSeekOpenAIEndpoint(raw string) bool {
298 path, ok := deepSeekOpenAIEndpointPath(raw)
299 return ok && path == ""
300 }
301
302 func isOfficialDeepSeekOpenAIEndpoint(raw string) bool {
303 path, ok := deepSeekOpenAIEndpointPath(raw)
304 return ok && (path == "" || path == "/v1")
305 }
306
307 func deepSeekOpenAIEndpointPath(raw string) (string, bool) {
308 u, err := url.Parse(strings.TrimSpace(raw))
309 if err != nil || !strings.EqualFold(u.Scheme, "https") ||
310 !strings.EqualFold(u.Hostname(), "api.deepseek.com") || u.Port() != "" ||
311 u.User != nil || u.RawQuery != "" || u.Fragment != "" {
312 return "", false
313 }
314 return strings.TrimRight(u.EscapedPath(), "/"), true
315 }
316
317 type providerTOMLBlock struct {
318 start int
319 end int
320 }
321
322 func providerTOMLBlocks(lines []string) []providerTOMLBlock {
323 headerLines := make([]int, 0)
324 providerStarts := make([]int, 0)
325 state := tomlOutside
326 for i, line := range lines {
327 if state != tomlOutside {
328 state = advanceTOMLStringState(state, line)
329 continue
330 }
331 if tomlSectionHeader(line) != "" {
332 headerLines = append(headerLines, i)
333 if isProviderArrayTableHeader(line) {
334 providerStarts = append(providerStarts, i)
335 }
336 }
337 state = advanceTOMLStringState(tomlOutside, line)
338 }
339 out := make([]providerTOMLBlock, 0, len(providerStarts))
340 for _, start := range providerStarts {
341 end := len(lines)
342 for _, header := range headerLines {
343 if header > start {
344 end = header
345 break
346 }
347 }
348 out = append(out, providerTOMLBlock{start: start, end: end})
349 }
350 return out
351 }
352
353 type providerTOMLInlineBlock struct {
354 chatEndpoints [][2]int
355 start, end int
356 kindStart, kindEnd int
357 baseURLStart, baseURLEnd int
358 fields map[string]providerTOMLInlineField
359 segments [][2]int
360 }
361
362 type providerTOMLInlineField struct {
363 valueStart, valueEnd int
364 segment int
365 }
366
367 type tomlReplacement struct {
368 start, end int
369 value string
370 }
371
372 // providerTOMLInlineBlocks locates providers declared as an inline TOML array
373 // while preserving byte offsets so migration can edit only two scalar values.
374 // The parser is deliberately lexical: BurntSushi/toml validates the document,
375 // while this scan handles nested arrays/tables and quoted delimiters without
376 // re-rendering comments or unknown fields.
377 func providerTOMLInlineBlocks(raw string) ([]providerTOMLInlineBlock, error) {
378 arrayStart, arrayEnd, err := providerTOMLInlineArrayRange(raw)
379 if err != nil {
380 return nil, err
381 }
382 return collectProviderTOMLInlineBlocks(raw, arrayStart, arrayEnd)
383 }
384
385 func providerTOMLInlineArrayRange(raw string) (int, int, error) {
386 arrayStart, arrayEnd := -1, -1
387 section := ""
388 state := tomlOutside
389 for _, span := range tomlLineSpans(raw) {
390 if state != tomlOutside {
391 state = advanceTOMLStringState(state, span.text)
392 continue
393 }
394 if header := tomlSectionHeader(span.text); header != "" {
395 section = header
396 state = advanceTOMLStringState(tomlOutside, span.text)
397 continue
398 }
399 if section != "" {
400 state = advanceTOMLStringState(tomlOutside, span.text)
401 continue
402 }
403 line := strings.TrimRight(span.text, "\r\n")
404 nextState := advanceTOMLStringState(tomlOutside, line)
405 key, _, ok := tomlKeyValue(line)
406 if !ok || strings.Trim(key, `"'`) != "providers" {
407 state = nextState
408 continue
409 }
410 equals := strings.IndexByte(line, '=')
411 valueStart := span.start + equals + 1
412 for valueStart < len(raw) && (raw[valueStart] == ' ' || raw[valueStart] == '\t' || raw[valueStart] == '\r' || raw[valueStart] == '\n') {
413 valueStart++
414 }
415 if valueStart >= len(raw) || raw[valueStart] != '[' {
416 state = nextState
417 continue
418 }
419 valueEnd, err := scanTOMLDelimitedValue(raw, valueStart, '[', ']')
420 if err != nil {
421 return -1, -1, err
422 }
423 arrayStart, arrayEnd = valueStart, valueEnd
424 break
425 }
426 if arrayStart < 0 {
427 return -1, -1, fmt.Errorf("providers inline array not found")
428 }
429 return arrayStart, arrayEnd, nil
430 }
431
432 func collectProviderTOMLInlineBlocks(raw string, arrayStart, arrayEnd int) ([]providerTOMLInlineBlock, error) {
433 var tables []providerTOMLInlineBlock
434 stack := make([]byte, 0, 4)
435 tableStart := -1
436 var scanErr error
437 err := scanTOMLOutsideStrings(raw, arrayStart, arrayEnd+1, func(pos int, ch byte) bool {
438 if scanErr != nil {
439 return false
440 }
441 switch ch {
442 case '[', '{':
443 stack = append(stack, ch)
444 if ch == '{' && len(stack) == 2 && stack[0] == '[' {
445 tableStart = pos
446 }
447 case ']', '}':
448 if len(stack) == 0 || (ch == ']' && stack[len(stack)-1] != '[') || (ch == '}' && stack[len(stack)-1] != '{') {
449 scanErr = fmt.Errorf("invalid providers inline array nesting")
450 return false
451 }
452 if ch == '}' && len(stack) == 2 && tableStart >= 0 {
453 block, err := parseProviderTOMLInlineBlock(raw, tableStart, pos)
454 if err != nil {
455 scanErr = err
456 return false
457 }
458 tables = append(tables, block)
459 tableStart = -1
460 }
461 stack = stack[:len(stack)-1]
462 }
463 return true
464 })
465 if scanErr != nil {
466 return nil, scanErr
467 }
468 if err != nil {
469 return nil, err
470 }
471 if len(stack) != 0 || len(tables) == 0 {
472 return nil, fmt.Errorf("providers inline array contains no provider tables")
473 }
474 return tables, nil
475 }
476
477 func parseProviderTOMLInlineBlock(raw string, start, end int) (providerTOMLInlineBlock, error) {
478 block := providerTOMLInlineBlock{
479 start: start, end: end, kindStart: -1, baseURLStart: -1,
480 fields: make(map[string]providerTOMLInlineField),
481 }
482 segmentStart := start + 1
483 depth := 0
484 var segments [][2]int
485 var scanErr error
486 err := scanTOMLOutsideStrings(raw, start+1, end, func(pos int, ch byte) bool {
487 if scanErr != nil {
488 return false
489 }
490 switch ch {
491 case '[', '{':
492 depth++
493 case ']', '}':
494 depth--
495 if depth < 0 {
496 scanErr = fmt.Errorf("invalid inline provider table nesting")
497 return false
498 }
499 case ',':
500 if depth == 0 {
501 segments = append(segments, [2]int{segmentStart, pos})
502 segmentStart = pos + 1
503 }
504 }
505 return true
506 })
507 if scanErr != nil {
508 return block, scanErr
509 }
510 if err != nil {
511 return block, err
512 }
513 segments = append(segments, [2]int{segmentStart, end})
514 block.segments = append(block.segments, segments...)
515 for segmentIndex, segment := range segments {
516 start, end := trimTOMLWhitespace(raw, segment[0], segment[1])
517 if start >= end {
518 continue
519 }
520 equals, err := findTOMLAssignmentEquals(raw, start, end)
521 if err != nil {
522 return block, err
523 }
524 if equals < 0 {
525 return block, fmt.Errorf("inline provider table contains a value without a key")
526 }
527 key := strings.Trim(strings.TrimSpace(raw[start:equals]), `"'`)
528 valueStart, valueEnd := trimTOMLWhitespace(raw, equals+1, end)
529 if comment := tomlInlineCommentIndex(raw[valueStart:valueEnd]); comment >= 0 {
530 valueEnd = valueStart + comment
531 valueStart, valueEnd = trimTOMLWhitespace(raw, valueStart, valueEnd)
532 }
533 block.fields[key] = providerTOMLInlineField{valueStart: valueStart, valueEnd: valueEnd, segment: segmentIndex}
534 switch key {
535 case "request_url", "chat_url":
536 // Empty overrides are equivalent to omission and stay empty.
537 if raw[valueStart:valueEnd] != `""` && raw[valueStart:valueEnd] != `''` {
538 block.chatEndpoints = append(block.chatEndpoints, [2]int{valueStart, valueEnd})
539 }
540 case "kind":
541 block.kindStart, block.kindEnd = valueStart, valueEnd
542 case "base_url":
543 block.baseURLStart, block.baseURLEnd = valueStart, valueEnd
544 }
545 }
546 return block, nil
547 }
548
549 func scanTOMLDelimitedValue(raw string, start int, open, close byte) (int, error) {
550 depth := 0
551 end := -1
552 var scanErr error
553 err := scanTOMLOutsideStrings(raw, start, len(raw), func(pos int, ch byte) bool {
554 switch ch {
555 case open:
556 depth++
557 case close:
558 depth--
559 if depth == 0 {
560 end = pos
561 return false
562 }
563 if depth < 0 {
564 scanErr = fmt.Errorf("invalid TOML array nesting")
565 return false
566 }
567 }
568 return true
569 })
570 if scanErr != nil {
571 return -1, scanErr
572 }
573 if err != nil {
574 return -1, err
575 }
576 if end < 0 {
577 return -1, fmt.Errorf("unterminated TOML inline array")
578 }
579 return end, nil
580 }
581
582 // scanTOMLOutsideStrings visits structural bytes outside TOML strings and
583 // comments. It is used only after BurntSushi/toml has validated the document.
584 func scanTOMLOutsideStrings(raw string, start, end int, visit func(int, byte) bool) error {
585 const (
586 outside = iota
587 basic
588 literal
589 multilineBasic
590 multilineLiteral
591 )
592 state, escaped := outside, false
593 for i := start; i < end; {
594 ch := raw[i]
595 switch state {
596 case basic:
597 if escaped {
598 escaped = false
599 i++
600 continue
601 }
602 switch ch {
603 case '\\':
604 escaped = true
605 case '"':
606 state = outside
607 }
608 i++
609 case literal:
610 if ch == '\'' {
611 state = outside
612 }
613 i++
614 case multilineBasic:
615 if escaped {
616 escaped = false
617 i++
618 continue
619 }
620 if ch == '\\' {
621 escaped = true
622 i++
623 continue
624 }
625 if strings.HasPrefix(raw[i:], `"""`) {
626 state = outside
627 i += 3
628 continue
629 }
630 i++
631 case multilineLiteral:
632 if strings.HasPrefix(raw[i:], "'''") {
633 state = outside
634 i += 3
635 continue
636 }
637 i++
638 default:
639 if ch == '#' {
640 for i < end && raw[i] != '\n' {
641 i++
642 }
643 continue
644 }
645 if ch == '"' {
646 run := 1
647 for i+run < end && raw[i+run] == '"' {
648 run++
649 }
650 if run >= 3 {
651 state = multilineBasic
652 i += 3
653 } else {
654 state = basic
655 i++
656 }
657 continue
658 }
659 if ch == '\'' {
660 run := 1
661 for i+run < end && raw[i+run] == '\'' {
662 run++
663 }
664 if run >= 3 {
665 state = multilineLiteral
666 i += 3
667 } else {
668 state = literal
669 i++
670 }
671 continue
672 }
673 if visit != nil && !visit(i, ch) {
674 return nil
675 }
676 i++
677 }
678 }
679 if state != outside {
680 return fmt.Errorf("unterminated TOML string")
681 }
682 return nil
683 }
684
685 func trimTOMLWhitespace(raw string, start, end int) (int, int) {
686 for start < end && strings.ContainsRune(" \t\r\n", rune(raw[start])) {
687 start++
688 }
689 for end > start && strings.ContainsRune(" \t\r\n", rune(raw[end-1])) {
690 end--
691 }
692 return start, end
693 }
694
695 func findTOMLAssignmentEquals(raw string, start, end int) (int, error) {
696 var found = -1
697 depth := 0
698 err := scanTOMLOutsideStrings(raw, start, end, func(pos int, ch byte) bool {
699 switch ch {
700 case '[', '{':
701 depth++
702 case ']', '}':
703 depth--
704 case '=':
705 if depth == 0 {
706 found = pos
707 return false
708 }
709 }
710 return true
711 })
712 return found, err
713 }
714
715 func applyTOMLReplacements(raw string, replacements []tomlReplacement) string {
716 sort.Slice(replacements, func(i, j int) bool { return replacements[i].start < replacements[j].start })
717 for _, r := range slices.Backward(replacements) {
718 raw = raw[:r.start] + r.value + raw[r.end:]
719 }
720 return raw
721 }
722
723 func isProviderArrayTableHeader(line string) bool {
724 trimmed := strings.TrimSpace(line)
725 if comment := tomlInlineCommentIndex(trimmed); comment >= 0 {
726 trimmed = strings.TrimSpace(trimmed[:comment])
727 }
728 if !strings.HasPrefix(trimmed, "[[") || !strings.HasSuffix(trimmed, "]]") {
729 return false
730 }
731 key := strings.TrimSpace(trimmed[2 : len(trimmed)-2])
732 switch {
733 case key == "providers", key == "'providers'":
734 return true
735 case len(key) >= 2 && key[0] == '"' && key[len(key)-1] == '"':
736 decoded, err := strconv.Unquote(key)
737 return err == nil && decoded == "providers"
738 default:
739 return false
740 }
741 }
742
742 lines GO