Add support for config inheritance
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -13,6 +14,15 @@ import (
|
||||
"github.com/tidwall/jsonc"
|
||||
)
|
||||
|
||||
// maxExtendsDepth limits inheritance chain depth to prevent infinite loops.
|
||||
const maxExtendsDepth = 10
|
||||
|
||||
// isPath returns true if the extends value looks like a file path rather than a template name.
|
||||
// A value is considered a path if it contains a path separator or starts with ".".
|
||||
func isPath(s string) bool {
|
||||
return strings.ContainsAny(s, "/\\") || strings.HasPrefix(s, ".")
|
||||
}
|
||||
|
||||
//go:embed *.json
|
||||
var templatesFS embed.FS
|
||||
|
||||
@@ -63,11 +73,30 @@ func List() []Template {
|
||||
}
|
||||
|
||||
// Load loads a template by name and returns the parsed config.
|
||||
// If the template uses "extends", the inheritance chain is resolved.
|
||||
func Load(name string) (*config.Config, error) {
|
||||
return loadWithDepth(name, 0, nil)
|
||||
}
|
||||
|
||||
// loadWithDepth loads a template with cycle and depth tracking.
|
||||
func loadWithDepth(name string, depth int, seen map[string]bool) (*config.Config, error) {
|
||||
if depth > maxExtendsDepth {
|
||||
return nil, fmt.Errorf("extends chain too deep (max %d)", maxExtendsDepth)
|
||||
}
|
||||
|
||||
// Normalize name (remove .json if present)
|
||||
name = strings.TrimSuffix(name, ".json")
|
||||
filename := name + ".json"
|
||||
|
||||
// Check for cycles
|
||||
if seen == nil {
|
||||
seen = make(map[string]bool)
|
||||
}
|
||||
if seen[name] {
|
||||
return nil, fmt.Errorf("circular extends detected: %q", name)
|
||||
}
|
||||
seen[name] = true
|
||||
|
||||
filename := name + ".json"
|
||||
data, err := templatesFS.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("template %q not found", name)
|
||||
@@ -78,6 +107,15 @@ func Load(name string) (*config.Config, error) {
|
||||
return nil, fmt.Errorf("failed to parse template %q: %w", name, err)
|
||||
}
|
||||
|
||||
// If this template extends another, resolve the chain
|
||||
if cfg.Extends != "" {
|
||||
baseCfg, err := loadWithDepth(cfg.Extends, depth+1, seen)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load base template %q: %w", cfg.Extends, err)
|
||||
}
|
||||
return config.Merge(baseCfg, &cfg), nil
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
@@ -95,3 +133,120 @@ func GetPath(name string) string {
|
||||
name = strings.TrimSuffix(name, ".json")
|
||||
return filepath.Join("internal/templates", name+".json")
|
||||
}
|
||||
|
||||
// ResolveExtends resolves the extends field in a config by loading and merging
|
||||
// the base template or config file. If the config has no extends field, it is returned as-is.
|
||||
// Relative paths are resolved relative to the current working directory.
|
||||
// Use ResolveExtendsWithBaseDir if you need to resolve relative to a specific directory.
|
||||
func ResolveExtends(cfg *config.Config) (*config.Config, error) {
|
||||
return ResolveExtendsWithBaseDir(cfg, "")
|
||||
}
|
||||
|
||||
// ResolveExtendsWithBaseDir resolves the extends field in a config.
|
||||
// The baseDir is used to resolve relative paths in the extends field.
|
||||
// If baseDir is empty, relative paths will be resolved relative to the current working directory.
|
||||
//
|
||||
// The extends field can be:
|
||||
// - A template name (e.g., "code", "npm-install")
|
||||
// - An absolute path (e.g., "/path/to/base.json")
|
||||
// - A relative path (e.g., "./base.json", "../shared/base.json")
|
||||
//
|
||||
// Paths are detected by the presence of "/" or "\" or a leading ".".
|
||||
func ResolveExtendsWithBaseDir(cfg *config.Config, baseDir string) (*config.Config, error) {
|
||||
if cfg == nil || cfg.Extends == "" {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
return resolveExtendsWithDepth(cfg, baseDir, 0, nil)
|
||||
}
|
||||
|
||||
// resolveExtendsWithDepth resolves extends with cycle and depth tracking.
|
||||
func resolveExtendsWithDepth(cfg *config.Config, baseDir string, depth int, seen map[string]bool) (*config.Config, error) {
|
||||
if cfg == nil || cfg.Extends == "" {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
if depth > maxExtendsDepth {
|
||||
return nil, fmt.Errorf("extends chain too deep (max %d)", maxExtendsDepth)
|
||||
}
|
||||
|
||||
if seen == nil {
|
||||
seen = make(map[string]bool)
|
||||
}
|
||||
|
||||
var baseCfg *config.Config
|
||||
var newBaseDir string
|
||||
var err error
|
||||
|
||||
// Handle file path or template name extends
|
||||
if isPath(cfg.Extends) {
|
||||
baseCfg, newBaseDir, err = loadConfigFile(cfg.Extends, baseDir, seen)
|
||||
} else {
|
||||
baseCfg, err = loadWithDepth(cfg.Extends, depth+1, seen)
|
||||
newBaseDir = ""
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If the base config also has extends, resolve it recursively
|
||||
if baseCfg.Extends != "" {
|
||||
baseCfg, err = resolveExtendsWithDepth(baseCfg, newBaseDir, depth+1, seen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return config.Merge(baseCfg, cfg), nil
|
||||
}
|
||||
|
||||
// loadConfigFile loads a config from a file path with cycle detection.
|
||||
// Returns the loaded config, the directory of the loaded file (for resolving nested extends), and any error.
|
||||
func loadConfigFile(path, baseDir string, seen map[string]bool) (*config.Config, string, error) {
|
||||
var resolvedPath string
|
||||
switch {
|
||||
case filepath.IsAbs(path):
|
||||
resolvedPath = path
|
||||
case baseDir != "":
|
||||
resolvedPath = filepath.Join(baseDir, path)
|
||||
default:
|
||||
var err error
|
||||
resolvedPath, err = filepath.Abs(path)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to resolve path %q: %w", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Clean and normalize the path for cycle detection
|
||||
resolvedPath = filepath.Clean(resolvedPath)
|
||||
|
||||
if seen[resolvedPath] {
|
||||
return nil, "", fmt.Errorf("circular extends detected: %q", path)
|
||||
}
|
||||
seen[resolvedPath] = true
|
||||
|
||||
data, err := os.ReadFile(resolvedPath) //nolint:gosec // user-provided config path - intentional
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, "", fmt.Errorf("extends file not found: %q", path)
|
||||
}
|
||||
return nil, "", fmt.Errorf("failed to read extends file %q: %w", path, err)
|
||||
}
|
||||
|
||||
// Handle empty file
|
||||
if len(strings.TrimSpace(string(data))) == 0 {
|
||||
return nil, "", fmt.Errorf("extends file is empty: %q", path)
|
||||
}
|
||||
|
||||
var cfg config.Config
|
||||
if err := json.Unmarshal(jsonc.ToJSON(data), &cfg); err != nil {
|
||||
return nil, "", fmt.Errorf("invalid JSON in extends file %q: %w", path, err)
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, "", fmt.Errorf("invalid configuration in extends file %q: %w", path, err)
|
||||
}
|
||||
|
||||
return &cfg, filepath.Dir(resolvedPath), nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user