From 802f0455794d5650b6b4498c06023a3050acb506 Mon Sep 17 00:00:00 2001 From: Randolph Sapp Date: Tue, 21 Apr 2026 15:26:04 -0500 Subject: [PATCH 1/2] fix(ini): expand paths during load Expand INI values known to be paths during initial loading. This prevents having to guess where paths came from in later processing. Signed-off-by: Randolph Sapp --- internal/core/ini.go | 108 +++++++++++++++++++++++++++++++------------ 1 file changed, 78 insertions(+), 30 deletions(-) diff --git a/internal/core/ini.go b/internal/core/ini.go index ad8630f3..38994acf 100644 --- a/internal/core/ini.go +++ b/internal/core/ini.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "io/fs" + "os" "path/filepath" "strings" @@ -13,6 +14,10 @@ import ( "github.com/errata-ai/vale/v3/internal/system" ) +var pathKeys = []string{ + "StylesPath", +} + var coreError = "'%s' is a core option; it should be defined above any syntax-specific options (`[...]`)." func mergeValues(shadows []string) []string { @@ -161,40 +166,15 @@ var globalOpts = map[string]func(*ini.Section, *Config){ var coreOpts = map[string]func(*ini.Section, *Config) error{ "StylesPath": func(sec *ini.Section, cfg *Config) error { - // NOTE: The order of these paths is important. They represent the load - // order of the configuration files -- not `cfg.Paths`. paths := sec.Key("StylesPath").ValueWithShadows() - files := cfg.ConfigFiles - if cfg.Flags.Local && len(files) == 2 { - // This represents the case where we have a default `.vale.ini` - // file and a local `.vale.ini` file. - // - // In such a case, there are three options: (1) both files define a - // `StylesPath`, (2) only one file defines a `StylesPath`, or (3) - // neither file defines a `StylesPath`. - basePath := system.DeterminePath(files[0], filepath.FromSlash(paths[0])) - mockPath := system.DeterminePath(files[1], filepath.FromSlash(paths[0])) - // ^ This case handles the situation where both configs define the - // same StylesPath (e.g., `StylesPath = styles`). - if len(paths) == 2 { - basePath = system.DeterminePath(files[0], filepath.FromSlash(paths[0])) - mockPath = system.DeterminePath(files[1], filepath.FromSlash(paths[1])) - } - cfg.AddStylesPath(basePath) - cfg.AddStylesPath(mockPath) - } else if len(paths) > 0 { - // In this case, we have a local configuration file (no default) - // that defines a `StylesPath`. - candidate := filepath.FromSlash(paths[len(paths)-1]) - path := system.DeterminePath(cfg.ConfigFile(), candidate) - - cfg.AddStylesPath(path) + for _, path := range paths { if !system.FileExists(path) { return NewE201FromTarget( fmt.Sprintf("The path '%s' does not exist.", path), - candidate, + path, cfg.Flags.Path) } + cfg.AddStylesPath(path) } return nil }, @@ -255,10 +235,78 @@ var coreOpts = map[string]func(*ini.Section, *Config) error{ }, } +func expandPaths(file *ini.File, source interface{}) { + var path string + + switch s := source.(type) { + case string: + abs, _ := filepath.Abs(s) + path = filepath.Dir(abs) + default: + path, _ = os.Getwd() + } + + for _, section := range file.Sections() { + for _, key := range section.Keys() { + if StringInSlice(key.Name(), pathKeys) { + value := key.Value() + if !filepath.IsAbs(value) { + key.SetValue(filepath.Join(path, value)) + } + } + } + } +} + +func shadowMerge(primary *ini.File, secondary *ini.File) { + for _, secondarySection := range secondary.Sections() { + sectionName := secondarySection.Name() + + primarySection, _ := primary.GetSection(sectionName) + if primarySection == nil { + primarySection, _ = primary.NewSection(sectionName) + } + + for _, secondaryKey := range secondarySection.Keys() { + keyName := secondaryKey.Name() + keyValue := secondaryKey.Value() + + primaryKey, _ := primarySection.GetKey(keyName) + if primaryKey == nil { + primarySection.NewKey(keyName, keyValue) + } else { + primaryKey.AddShadow(keyValue) + } + } + } +} + func shadowLoad(source interface{}, others ...interface{}) (*ini.File, error) { - return ini.LoadSources(ini.LoadOptions{ + options := ini.LoadOptions{ AllowShadows: true, - SpaceBeforeInlineComment: true}, source, others...) + Loose: true, + SpaceBeforeInlineComment: true, + } + + primary, err := ini.LoadSources(options, source) + if err != nil { + return nil, err + } + expandPaths(primary, source) + + for _, other := range others { + var shadow *ini.File + + shadow, err = ini.LoadSources(options, other) + if err != nil { + return nil, err + } + + expandPaths(shadow, other) + shadowMerge(primary, shadow) + } + + return primary, nil } func processSources(cfg *Config, sources []string) (*ini.File, error) { From 415a2837288b07e0d61815bee295b93b705e5131 Mon Sep 17 00:00:00 2001 From: Randolph Sapp Date: Tue, 21 Apr 2026 15:28:22 -0500 Subject: [PATCH 2/2] fix(loadINI): use standard shadow loading Instead of appending configurations directly we should use the common shadow loading functions we've defined. This allows paths to be expanded properly ahead of time, and still use the shadow processing mechanisms other mechanisms expect. This also enforces the load order through the list of sources built and passed to processSources. Signed-off-by: Randolph Sapp --- internal/core/source.go | 74 +++++++++++++++-------------------------- 1 file changed, 27 insertions(+), 47 deletions(-) diff --git a/internal/core/source.go b/internal/core/source.go index f3ad48c1..3e3ae062 100644 --- a/internal/core/source.go +++ b/internal/core/source.go @@ -100,11 +100,25 @@ func loadStdin(src string, cfg *Config, dry bool) (*ini.File, error) { } func loadINI(cfg *Config, dry bool) (*ini.File, error) { - uCfg := ini.Empty(ini.LoadOptions{ - AllowShadows: true, - Loose: true, - SpaceBeforeInlineComment: true, - }) + var sources []string + var uCfg *ini.File + + // NOTE: In v3.0, we now use the user's config directory as the default + // location. + // + // This is different from the other config-defining options (`--config`, + // `VALE_CONFIG_PATH`, etc.) in that it's loaded in addition to, rather + // than instead of, any other configuration sources. + // + // In other words, this config file is *always* loaded and is read after + // any other sources to allow for project-agnostic customization. + defaultCfg, _ := DefaultConfig() + + if system.FileExists(defaultCfg) && !cfg.Flags.IgnoreGlobal && !dry { + sources = append(sources, defaultCfg) + cfg.Flags.Local = true + cfg.AddConfigFile(defaultCfg) + } base, err := loadConfig(configNames) if err != nil { @@ -115,41 +129,22 @@ func loadINI(cfg *Config, dry bool) (*ini.File, error) { if cfg.Flags.Sources != "" { // NOTE: This case shouldn't be accessible from the CLI, but it can // still be triggered by packages that include config files. - var sources []string for _, source := range strings.Split(cfg.Flags.Sources, ",") { abs, _ := filepath.Abs(source) sources = append(sources, abs) } - - // We have multiple sources -- e.g., local config + remote package(s). - // - // See fixtures/config.feature#451 for an explanation of how this has - // changed since Vale Server was deprecated. - uCfg, err = processSources(cfg, sources) - if err != nil { - return nil, NewE100("config pipeline failed", err) - } } else if cfg.Flags.Path != "" { // We've been given a value through `--config`. - err = uCfg.Append(cfg.Flags.Path) - if err != nil { - return nil, NewE100("invalid --config", err) - } + sources = append(sources, cfg.Flags.Path) cfg.AddConfigFile(cfg.Flags.Path) } else if fromEnv, hasEnv := os.LookupEnv("VALE_CONFIG_PATH"); hasEnv { // We've been given a value through `VALE_CONFIG_PATH`. - err = uCfg.Append(fromEnv) - if err != nil { - return nil, NewE100("invalid VALE_CONFIG_PATH", err) - } + sources = append(sources, fromEnv) cfg.AddConfigFile(fromEnv) } else if base != "" { // We're using a config file found using a local search process. - err = uCfg.Append(base) - if err != nil { - return nil, NewE100(".vale.ini not found", err) - } + sources = append(sources, base) cfg.AddConfigFile(base) } @@ -157,29 +152,14 @@ func loadINI(cfg *Config, dry bool) (*ini.File, error) { cfg.MinAlertLevel = LevelToInt[cfg.Flags.AlertLevel] } - // NOTE: In v3.0, we now use the user's config directory as the default - // location. - // - // This is different from the other config-defining options (`--config`, - // `VALE_CONFIG_PATH`, etc.) in that it's loaded in addition to, rather - // than instead of, any other configuration sources. - // - // In other words, this config file is *always* loaded and is read after - // any other sources to allow for project-agnostic customization. - defaultCfg, _ := DefaultConfig() - - if system.FileExists(defaultCfg) && !cfg.Flags.IgnoreGlobal && !dry { - err = uCfg.Append(defaultCfg) - if err != nil { - return nil, NewE100("default/ini", err) - } - cfg.Flags.Local = true - cfg.AddConfigFile(defaultCfg) - } else if base == "" && len(cfg.ConfigFiles) == 0 && !dry { + if base == "" && len(cfg.ConfigFiles) == 0 && !dry { return nil, NewE100(".vale.ini not found", errors.New("no config file found")) } - uCfg.BlockMode = false + uCfg, err = processSources(cfg, sources) + if err != nil { + return nil, NewE100("config pipeline failed", err) + } return processConfig(uCfg, cfg, dry) }