diff --git a/docs/wiki/Configuration:-Include.md b/docs/wiki/Configuration:-Include.md
index db4817cc..bbf6a955 100644
--- a/docs/wiki/Configuration:-Include.md
+++ b/docs/wiki/Configuration:-Include.md
@@ -114,6 +114,30 @@ window-rule {
}
```
+### Optional includes
+
+Since: next release
+
+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.
diff --git a/niri-config/src/lib.rs b/niri-config/src/lib.rs
index 545ae5a4..b61fe1c1 100644
--- a/niri-config/src/lib.rs
+++ b/niri-config/src/lib.rs
@@ -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::().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}"),
+ ));
+ }
}
}
}