|
1 | 1 | package config |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "bytes" |
4 | 5 | "reflect" |
5 | 6 | "strings" |
6 | 7 |
|
@@ -30,6 +31,11 @@ func TransparentUnmarshal(data []byte, v any) error { |
30 | 31 | // with default values (e.g., types.MustParseFIL("0")) before calling this function. |
31 | 32 | // NOTE: FixTOML should be called BEFORE this function to ensure proper slice lengths and FIL initialization. |
32 | 33 | func TransparentDecode(data string, v any) (toml.MetaData, error) { |
| 34 | + data, err := StripEmptyDynamicTables(data, v) |
| 35 | + if err != nil { |
| 36 | + return toml.MetaData{}, err |
| 37 | + } |
| 38 | + |
33 | 39 | // Create a shadow struct to decode into |
34 | 40 | shadow := createShadowStruct(v) |
35 | 41 |
|
@@ -331,6 +337,170 @@ func wrapDynamics(shadow, target any) error { |
331 | 337 | return nil |
332 | 338 | } |
333 | 339 |
|
| 340 | +// StripEmptyDynamicTables drops empty tables left by raw toml.Encode of Dynamic[T]. |
| 341 | +// Prefer this for in-memory decode paths; use RepairEmptyDynamicTablesText when |
| 342 | +// persisting a cleaned layer so comments and surrounding formatting are kept. |
| 343 | +func StripEmptyDynamicTables(text string, sample any) (string, error) { |
| 344 | + fixed, _, err := RepairEmptyDynamicTablesText(text, sample) |
| 345 | + return fixed, err |
| 346 | +} |
| 347 | + |
| 348 | +// RepairEmptyDynamicTablesText removes empty Dynamic[T] wrapper tables from TOML |
| 349 | +// while preserving comments and non-corrupt content. changed is true when any |
| 350 | +// empty wrapper table header was dropped. |
| 351 | +func RepairEmptyDynamicTablesText(text string, sample any) (string, bool, error) { |
| 352 | + if strings.TrimSpace(text) == "" || sample == nil { |
| 353 | + return text, false, nil |
| 354 | + } |
| 355 | + |
| 356 | + var raw map[string]any |
| 357 | + if err := toml.Unmarshal([]byte(text), &raw); err != nil { |
| 358 | + // Leave unloadable TOML alone for callers that handle decode errors. |
| 359 | + return text, false, nil |
| 360 | + } |
| 361 | + |
| 362 | + var empty [][]string |
| 363 | + for _, path := range collectDynamicTOMLPaths(reflect.TypeOf(sample), nil) { |
| 364 | + if emptyMapAtPath(raw, path) { |
| 365 | + empty = append(empty, path) |
| 366 | + } |
| 367 | + } |
| 368 | + if len(empty) == 0 { |
| 369 | + return text, false, nil |
| 370 | + } |
| 371 | + |
| 372 | + fixed := removeTOMLTableHeaders(text, empty) |
| 373 | + if fixed == text { |
| 374 | + // Header lines were not found (unusual encoding); fall back to re-encode. |
| 375 | + for _, path := range empty { |
| 376 | + _ = deleteEmptyMapAtPath(raw, path) |
| 377 | + } |
| 378 | + var buf bytes.Buffer |
| 379 | + if err := toml.NewEncoder(&buf).Encode(raw); err != nil { |
| 380 | + return "", false, err |
| 381 | + } |
| 382 | + return buf.String(), true, nil |
| 383 | + } |
| 384 | + return fixed, true, nil |
| 385 | +} |
| 386 | + |
| 387 | +func emptyMapAtPath(m map[string]any, path []string) bool { |
| 388 | + if len(path) == 0 || m == nil { |
| 389 | + return false |
| 390 | + } |
| 391 | + _, val, ok := mapLookupCI(m, path[0]) |
| 392 | + if !ok { |
| 393 | + return false |
| 394 | + } |
| 395 | + child, isMap := val.(map[string]any) |
| 396 | + if !isMap { |
| 397 | + return false |
| 398 | + } |
| 399 | + if len(path) > 1 { |
| 400 | + return emptyMapAtPath(child, path[1:]) |
| 401 | + } |
| 402 | + return len(child) == 0 |
| 403 | +} |
| 404 | + |
| 405 | +// removeTOMLTableHeaders drops plain table header lines whose dotted names match |
| 406 | +// paths (case-insensitive). Array tables ([[...]]) are left untouched. |
| 407 | +func removeTOMLTableHeaders(text string, paths [][]string) string { |
| 408 | + want := make(map[string]struct{}, len(paths)) |
| 409 | + for _, path := range paths { |
| 410 | + want[strings.ToLower(strings.Join(path, "."))] = struct{}{} |
| 411 | + } |
| 412 | + |
| 413 | + lines := strings.Split(text, "\n") |
| 414 | + out := make([]string, 0, len(lines)) |
| 415 | + for _, line := range lines { |
| 416 | + trimmed := strings.TrimSpace(line) |
| 417 | + if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") && !strings.HasPrefix(trimmed, "[[") { |
| 418 | + inner := strings.TrimSpace(trimmed[1 : len(trimmed)-1]) |
| 419 | + if _, ok := want[strings.ToLower(inner)]; ok { |
| 420 | + continue |
| 421 | + } |
| 422 | + } |
| 423 | + out = append(out, line) |
| 424 | + } |
| 425 | + return strings.Join(out, "\n") |
| 426 | +} |
| 427 | + |
| 428 | +func collectDynamicTOMLPaths(t reflect.Type, prefix []string) [][]string { |
| 429 | + if t == nil { |
| 430 | + return nil |
| 431 | + } |
| 432 | + if t.Kind() == reflect.Pointer { |
| 433 | + t = t.Elem() |
| 434 | + } |
| 435 | + if t.Kind() != reflect.Struct { |
| 436 | + return nil |
| 437 | + } |
| 438 | + |
| 439 | + var out [][]string |
| 440 | + for field := range t.Fields() { |
| 441 | + if !field.IsExported() { |
| 442 | + continue |
| 443 | + } |
| 444 | + name := tomlFieldName(field) |
| 445 | + if name == "-" { |
| 446 | + continue |
| 447 | + } |
| 448 | + path := append(append([]string{}, prefix...), name) |
| 449 | + if isDynamicTypeForMarshal(field.Type) { |
| 450 | + out = append(out, path) |
| 451 | + continue |
| 452 | + } |
| 453 | + out = append(out, collectDynamicTOMLPaths(field.Type, path)...) |
| 454 | + } |
| 455 | + return out |
| 456 | +} |
| 457 | + |
| 458 | +func tomlFieldName(f reflect.StructField) string { |
| 459 | + tag := f.Tag.Get("toml") |
| 460 | + if tag == "" { |
| 461 | + return f.Name |
| 462 | + } |
| 463 | + name, _, _ := strings.Cut(tag, ",") |
| 464 | + if name == "" { |
| 465 | + return f.Name |
| 466 | + } |
| 467 | + return name |
| 468 | +} |
| 469 | + |
| 470 | +func deleteEmptyMapAtPath(m map[string]any, path []string) bool { |
| 471 | + if len(path) == 0 || m == nil { |
| 472 | + return false |
| 473 | + } |
| 474 | + key, val, ok := mapLookupCI(m, path[0]) |
| 475 | + if !ok { |
| 476 | + return false |
| 477 | + } |
| 478 | + child, isMap := val.(map[string]any) |
| 479 | + if !isMap { |
| 480 | + return false |
| 481 | + } |
| 482 | + if len(path) > 1 { |
| 483 | + return deleteEmptyMapAtPath(child, path[1:]) |
| 484 | + } |
| 485 | + if len(child) > 0 { |
| 486 | + return false |
| 487 | + } |
| 488 | + delete(m, key) |
| 489 | + return true |
| 490 | +} |
| 491 | + |
| 492 | +func mapLookupCI(m map[string]any, key string) (string, any, bool) { |
| 493 | + if v, ok := m[key]; ok { |
| 494 | + return key, v, true |
| 495 | + } |
| 496 | + for k, v := range m { |
| 497 | + if strings.EqualFold(k, key) { |
| 498 | + return k, v, true |
| 499 | + } |
| 500 | + } |
| 501 | + return "", nil, false |
| 502 | +} |
| 503 | + |
334 | 504 | // isDynamicTypeForMarshal checks if a type is Dynamic[T] |
335 | 505 | // (renamed to avoid conflict with isDynamicType in dynamic.go) |
336 | 506 | func isDynamicTypeForMarshal(t reflect.Type) bool { |
@@ -399,6 +569,14 @@ func extractDynamicValue(v reflect.Value) reflect.Value { |
399 | 569 | return results[0] |
400 | 570 | } |
401 | 571 |
|
| 572 | +// DynamicInnerType returns T from Dynamic[T] or *Dynamic[T]. |
| 573 | +func DynamicInnerType(t reflect.Type) (reflect.Type, bool) { |
| 574 | + if !isDynamicTypeForMarshal(t) { |
| 575 | + return nil, false |
| 576 | + } |
| 577 | + return extractDynamicInnerType(t), true |
| 578 | +} |
| 579 | + |
402 | 580 | // extractDynamicInnerType gets the T from Dynamic[T] |
403 | 581 | func extractDynamicInnerType(t reflect.Type) reflect.Type { |
404 | 582 | // Handle pointer to Dynamic |
|
0 commit comments