Support XDG Base Directory spec for .mlrrc location (#1271)

Adds $XDG_CONFIG_HOME/miller/mlrrc (defaulting to $HOME/.config/miller/mlrrc
when $XDG_CONFIG_HOME is unset) as an additional stacking location, processed
after $HOME/.mlrrc and before ./.mlrrc. Fully backward-compatible: existing
$HOME/.mlrrc and ./.mlrrc behavior is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
John Kerl 2026-07-16 18:49:56 -04:00
parent 4573618433
commit a1fc51546e
8 changed files with 155 additions and 24 deletions

View file

@ -121,9 +121,10 @@ Semantics:
* If the same section name appears more than once, the settings from all its blocks are applied,
in the order they appear in the file.
* If both `$HOME/.mlrrc` and `./.mlrrc` are processed, each file's global settings and matching
section settings are applied in that per-file order, `$HOME/.mlrrc` first. The selected profile
needs to exist in only one of them.
* If more than one of `$HOME/.mlrrc`, `$XDG_CONFIG_HOME/miller/mlrrc` (or its default
`$HOME/.config/miller/mlrrc`), and `./.mlrrc` are processed, each file's global settings and
matching section settings are applied in that per-file order. The selected profile needs to
exist in only one of them.
* Since `--profile` selects a section of your `.mlrrc`, it can't be combined with `--norc`, or
with `MLRRC=__none__` in the environment -- that's a fatal error.
@ -139,14 +140,18 @@ If the environment variable `MLRRC` is set:
* Otherwise, its value (as a filename) is loaded and processed. If there are syntax errors, they abort `mlr` with a usage message (as if you had mistyped something on the command line). If the file can't be loaded at all, though, it is silently skipped.
* Any `.mlrrc` in your home directory or current directory is ignored whenever `MLRRC` is set in the environment.
* Any `.mlrrc` in your home directory, XDG config directory, or current directory is ignored whenever `MLRRC` is set in the environment.
* Example line in your shell's rc file: `export MLRRC=/path/to/my/mlrrc`
Otherwise:
Otherwise, each of the following which exists is processed in turn, letting them stack: the idea
is you can have all your settings in your `$HOME/.mlrrc`, then maybe more project-specific
settings for your current directory if you like.
* If `$HOME/.mlrrc` exists, it's processed as above.
* If `./.mlrrc` exists, it's then also processed as above.
* If `$XDG_CONFIG_HOME/miller/mlrrc` exists, it's then also processed as above, per the
[XDG Base Directory Specification](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html).
If `$XDG_CONFIG_HOME` isn't set, `$HOME/.config/miller/mlrrc` is used instead.
* The idea is you can have all your settings in your `$HOME/.mlrrc`, then maybe more project-specific settings for your current directory if you like.
* If `./.mlrrc` exists, it's then also processed as above.

View file

@ -89,9 +89,10 @@ Semantics:
* If the same section name appears more than once, the settings from all its blocks are applied,
in the order they appear in the file.
* If both `$HOME/.mlrrc` and `./.mlrrc` are processed, each file's global settings and matching
section settings are applied in that per-file order, `$HOME/.mlrrc` first. The selected profile
needs to exist in only one of them.
* If more than one of `$HOME/.mlrrc`, `$XDG_CONFIG_HOME/miller/mlrrc` (or its default
`$HOME/.config/miller/mlrrc`), and `./.mlrrc` are processed, each file's global settings and
matching section settings are applied in that per-file order. The selected profile needs to
exist in only one of them.
* Since `--profile` selects a section of your `.mlrrc`, it can't be combined with `--norc`, or
with `MLRRC=__none__` in the environment -- that's a fatal error.
@ -107,14 +108,18 @@ If the environment variable `MLRRC` is set:
* Otherwise, its value (as a filename) is loaded and processed. If there are syntax errors, they abort `mlr` with a usage message (as if you had mistyped something on the command line). If the file can't be loaded at all, though, it is silently skipped.
* Any `.mlrrc` in your home directory or current directory is ignored whenever `MLRRC` is set in the environment.
* Any `.mlrrc` in your home directory, XDG config directory, or current directory is ignored whenever `MLRRC` is set in the environment.
* Example line in your shell's rc file: `export MLRRC=/path/to/my/mlrrc`
Otherwise:
Otherwise, each of the following which exists is processed in turn, letting them stack: the idea
is you can have all your settings in your `$HOME/.mlrrc`, then maybe more project-specific
settings for your current directory if you like.
* If `$HOME/.mlrrc` exists, it's processed as above.
* If `./.mlrrc` exists, it's then also processed as above.
* If `$XDG_CONFIG_HOME/miller/mlrrc` exists, it's then also processed as above, per the
[XDG Base Directory Specification](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html).
If `$XDG_CONFIG_HOME` isn't set, `$HOME/.config/miller/mlrrc` is used instead.
* The idea is you can have all your settings in your `$HOME/.mlrrc`, then maybe more project-specific settings for your current directory if you like.
* If `./.mlrrc` exists, it's then also processed as above.

View file

@ -4173,5 +4173,5 @@ This is simply a copy of what you should see on running `man mlr` at a command p
MIME Type for Comma-Separated Values (CSV) Files, the Miller docsite
https://miller.readthedocs.io
2026-07-15 4mMILLER24m(1)
2026-07-16 4mMILLER24m(1)
</pre>

View file

@ -4152,4 +4152,4 @@
MIME Type for Comma-Separated Values (CSV) Files, the Miller docsite
https://miller.readthedocs.io
2026-07-15 4mMILLER24m(1)
2026-07-16 4mMILLER24m(1)

View file

@ -4152,4 +4152,4 @@
MIME Type for Comma-Separated Values (CSV) Files, the Miller docsite
https://miller.readthedocs.io
2026-07-15 4mMILLER24m(1)
2026-07-16 4mMILLER24m(1)

View file

@ -2,12 +2,12 @@
.\" Title: mlr
.\" Author: [see the "AUTHOR" section]
.\" Generator: ./mkman.rb
.\" Date: 2026-07-15
.\" Date: 2026-07-16
.\" Manual: \ \&
.\" Source: \ \&
.\" Language: English
.\"
.TH "MILLER" "1" "2026-07-15" "\ \&" "\ \&"
.TH "MILLER" "1" "2026-07-16" "\ \&" "\ \&"
.\" -----------------------------------------------------------------
.\" * Portability definitions
.\" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View file

@ -11,10 +11,11 @@ import (
"github.com/johnkerl/miller/v6/pkg/cli"
)
// loadMlrrcFiles rule: If $MLRRC is set, use it and only it. Otherwise try first
// $HOME/.mlrrc and then ./.mlrrc but let them stack: e.g. $HOME/.mlrrc is
// lots of settings and maybe in one subdir you want to override just a
// setting or two.
// loadMlrrcFiles rule: If $MLRRC is set, use it and only it. Otherwise try, in
// order, $HOME/.mlrrc, then $XDG_CONFIG_HOME/miller/mlrrc (defaulting to
// $HOME/.config/miller/mlrrc if $XDG_CONFIG_HOME is unset), then ./.mlrrc --
// but let them stack: e.g. $HOME/.mlrrc is lots of settings and maybe in one
// subdir you want to override just a setting or two.
//
// The profileName argument comes from the --profile {name} / -P {name} main
// flag. Empty string means no profile was requested: only global (pre-section)
@ -62,6 +63,21 @@ func loadMlrrcFiles(
}
}
xdgConfigHome := os.Getenv("XDG_CONFIG_HOME")
if xdgConfigHome == "" && env_home != "" {
xdgConfigHome = env_home + "/.config"
}
if xdgConfigHome != "" {
path := xdgConfigHome + "/miller/mlrrc"
loaded, err := tryLoadMlrrc(options, path, profileName, &foundProfile)
if err != nil {
return err
}
if loaded {
loadedPaths = append(loadedPaths, path)
}
}
loaded, err := tryLoadMlrrc(options, "./.mlrrc", profileName, &foundProfile)
if err != nil {
return err

View file

@ -128,9 +128,11 @@ func TestMlrrcProfileWithMlrrcNoneIsError(t *testing.T) {
func TestMlrrcProfileWithNoMlrrcFileIsError(t *testing.T) {
// Point MLRRC at a nonexistent file: an unopenable file is silently
// skipped (the normal no-.mlrrc case), and HOME is remapped to an empty
// temp directory, so no .mlrrc file is processed at all.
// temp directory, so no .mlrrc file is processed at all. XDG_CONFIG_HOME
// is cleared so a developer's real config dir doesn't leak in.
t.Setenv("MLRRC", filepath.Join(t.TempDir(), "nonexistent"))
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", "")
options := cli.DefaultOptions()
err := loadMlrrcFiles(options, "j")
if err == nil {
@ -197,6 +199,109 @@ func TestMlrrcPrepipeStillDisallowed(t *testing.T) {
}
}
func TestMlrrcXdgConfigHomeIsLoaded(t *testing.T) {
// No $HOME/.mlrrc, no ./.mlrrc, no $MLRRC: only
// $XDG_CONFIG_HOME/miller/mlrrc is processed.
xdgConfigHome := t.TempDir()
if err := os.MkdirAll(filepath.Join(xdgConfigHome, "miller"), 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(xdgConfigHome, "miller", "mlrrc")
if err := os.WriteFile(path, []byte("ojson\n"), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("MLRRC", "")
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", xdgConfigHome)
oldwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(t.TempDir()); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(oldwd) }()
options := cli.DefaultOptions()
if err := loadMlrrcFiles(options, ""); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if options.WriterOptions.OutputFileFormat != "json" {
t.Errorf("output format: got %s, want json", options.WriterOptions.OutputFileFormat)
}
}
func TestMlrrcXdgConfigHomeDefaultsToHomeConfig(t *testing.T) {
// When $XDG_CONFIG_HOME is unset, $HOME/.config/miller/mlrrc is used.
home := t.TempDir()
if err := os.MkdirAll(filepath.Join(home, ".config", "miller"), 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(home, ".config", "miller", "mlrrc")
if err := os.WriteFile(path, []byte("ojson\n"), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("MLRRC", "")
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", "")
oldwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(t.TempDir()); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(oldwd) }()
options := cli.DefaultOptions()
if err := loadMlrrcFiles(options, ""); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if options.WriterOptions.OutputFileFormat != "json" {
t.Errorf("output format: got %s, want json", options.WriterOptions.OutputFileFormat)
}
}
func TestMlrrcXdgConfigHomeStacksAfterHomeMlrrc(t *testing.T) {
// Both $HOME/.mlrrc and $XDG_CONFIG_HOME/miller/mlrrc are processed, with
// $HOME/.mlrrc applied first: a setting in the XDG file overrides one
// from $HOME/.mlrrc.
home := t.TempDir()
if err := os.WriteFile(filepath.Join(home, ".mlrrc"), []byte("icsv\nojson\n"), 0o644); err != nil {
t.Fatal(err)
}
xdgConfigHome := t.TempDir()
if err := os.MkdirAll(filepath.Join(xdgConfigHome, "miller"), 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(xdgConfigHome, "miller", "mlrrc")
if err := os.WriteFile(path, []byte("otsv\n"), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("MLRRC", "")
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", xdgConfigHome)
oldwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(t.TempDir()); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(oldwd) }()
options := cli.DefaultOptions()
if err := loadMlrrcFiles(options, ""); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if options.ReaderOptions.InputFileFormat != "csv" {
t.Errorf("input format: got %s, want csv", options.ReaderOptions.InputFileFormat)
}
if options.WriterOptions.OutputFileFormat != "tsv" {
t.Errorf("output format: got %s, want tsv (from XDG config, overriding json from $HOME/.mlrrc)", options.WriterOptions.OutputFileFormat)
}
}
func TestMlrrcProfileFlagDisallowedWithinMlrrc(t *testing.T) {
// Profiles are selected on the mlr command line, not from within a
// .mlrrc file: --profile / -P there is a parse error, like --prepipe.