From a1fc51546ebcc9497b6692d1acb0d2e0e751ba48 Mon Sep 17 00:00:00 2001 From: John Kerl Date: Thu, 16 Jul 2026 18:49:56 -0400 Subject: [PATCH] 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 --- docs/src/customization.md | 19 ++++-- docs/src/customization.md.in | 19 ++++-- docs/src/manpage.md | 2 +- docs/src/manpage.txt | 2 +- man/manpage.txt | 2 +- man/mlr.1 | 4 +- pkg/climain/mlrcli_mlrrc.go | 24 +++++-- pkg/climain/mlrcli_mlrrc_test.go | 107 ++++++++++++++++++++++++++++++- 8 files changed, 155 insertions(+), 24 deletions(-) diff --git a/docs/src/customization.md b/docs/src/customization.md index c4befde21..5b7ef9f4c 100644 --- a/docs/src/customization.md +++ b/docs/src/customization.md @@ -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. diff --git a/docs/src/customization.md.in b/docs/src/customization.md.in index be84ad4fc..d94904346 100644 --- a/docs/src/customization.md.in +++ b/docs/src/customization.md.in @@ -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. diff --git a/docs/src/manpage.md b/docs/src/manpage.md index 782050585..68ddaf9c9 100644 --- a/docs/src/manpage.md +++ b/docs/src/manpage.md @@ -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) diff --git a/docs/src/manpage.txt b/docs/src/manpage.txt index feb351a26..232cec360 100644 --- a/docs/src/manpage.txt +++ b/docs/src/manpage.txt @@ -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) diff --git a/man/manpage.txt b/man/manpage.txt index feb351a26..232cec360 100644 --- a/man/manpage.txt +++ b/man/manpage.txt @@ -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) diff --git a/man/mlr.1 b/man/mlr.1 index e246cde7f..e6f56b26a 100644 --- a/man/mlr.1 +++ b/man/mlr.1 @@ -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 .\" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/pkg/climain/mlrcli_mlrrc.go b/pkg/climain/mlrcli_mlrrc.go index c7eacd213..aaecb3a9c 100644 --- a/pkg/climain/mlrcli_mlrrc.go +++ b/pkg/climain/mlrcli_mlrrc.go @@ -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 diff --git a/pkg/climain/mlrcli_mlrrc_test.go b/pkg/climain/mlrcli_mlrrc_test.go index c0f1b726d..b8cfe6de8 100644 --- a/pkg/climain/mlrcli_mlrrc_test.go +++ b/pkg/climain/mlrcli_mlrrc_test.go @@ -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.