Implement include optional=true (#3022)

* feat(niri): support `include optional=true "filename.kdl"`

* chore: warn if optional include ENOENT

* chore: validate include directive arguments and properties

Add proper validation to reject:
- Extra arguments beyond the path
- Unknown properties (other than "optional")
- Unexpected child nodes

* docs: implement suggested typographical/prose changes

* fixes

---------

Co-authored-by: Ivan Molodetskikh <yalterz@gmail.com>
This commit is contained in:
John Rinehart 2025-12-20 00:04:18 -05:00 committed by GitHub
parent c4462d0c7f
commit 7a237e519c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 79 additions and 5 deletions

View file

@ -114,6 +114,30 @@ window-rule {
}
```
### Optional includes
<sup>Since: next release</sup>
By default, including a nonexistent file will cause an error.
You can allow nonexistent includes by setting `optional=true`:
```kdl,must-fail
// Won't fail if this file doesn't exist.
include optional=true "optional-config.kdl"
// Regular include, will fail if the file doesn't exist.
include "required-config.kdl"
```
When an optional include file is missing, niri will emit a warning in the logs on every config reload.
This reminds you that the file is missing while still loading the config successfully.
The optional file is still watched for changes, so if you create it later, the config will automatically reload and apply the new settings.
Note that `optional` only affects whether a missing file causes an error.
If the file exists but contains invalid syntax or other errors, those errors will still cause a parsing failure.
### Merging
Most config sections are merged between includes, meaning that you can set only a few properties, and only those properties will change.

View file

@ -291,7 +291,51 @@ where
}
"include" => {
let path: PathBuf = utils::parse_arg_node("include", node, ctx)?;
// Parse the path argument
let mut iter_args = node.arguments.iter();
let path_val = iter_args.next().ok_or_else(|| {
DecodeError::missing(
node,
"additional argument for include path is required",
)
})?;
let path: PathBuf = knuffel::traits::DecodeScalar::decode(path_val, ctx)?;
// Check for extra arguments
if let Some(val) = iter_args.next() {
ctx.emit_error(DecodeError::unexpected(
&val.literal,
"argument",
"unexpected argument",
));
}
// Parse the optional property
let mut optional = false;
for (name, val) in &node.properties {
match &***name {
"optional" => {
optional = knuffel::traits::DecodeScalar::decode(val, ctx)?;
}
name_str => {
ctx.emit_error(DecodeError::unexpected(
name,
"property",
format!("unexpected property `{}`", name_str.escape_default()),
));
}
}
}
// Check for unexpected children
for child in node.children() {
ctx.emit_error(DecodeError::unexpected(
child,
"node",
format!("unexpected node `{}`", child.node_name.escape_default()),
));
}
let base = ctx.get::<BasePath>().unwrap();
let path = base.0.join(path);
@ -369,10 +413,16 @@ where
}
}
Err(err) => {
ctx.emit_error(DecodeError::missing(
node,
format!("failed to read included config from {path:?}: {err}"),
));
if optional && err.kind() == std::io::ErrorKind::NotFound {
// Warn about missing optional includes
warn!("optional include not found: {path:?}");
} else {
// Report all other errors normally
ctx.emit_error(DecodeError::missing(
node,
format!("failed to read included config from {path:?}: {err}"),
));
}
}
}
}