From 8b753dc7410b29b7beec36a7d82a7b86fb527000 Mon Sep 17 00:00:00 2001 From: Ruud van Asseldonk Date: Wed, 8 Apr 2026 15:03:23 +0200 Subject: [PATCH 01/22] Add CLI option for systemd output format --- src/cli.rs | 5 ++++- src/cmd_build.rs | 1 + src/cmd_eval.rs | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/cli.rs b/src/cli.rs index edabba97..baacbe95 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -158,6 +158,7 @@ Output format: document is a list or set of strings, output each string on its own line. rcl Output pretty-printed RCL. + systemd Output as systemd unit. The document must be a 2-level dict. toml Alias for 'toml-1.0'. toml-1.0 Output TOML 1.0, where tables are on a single line. toml-1.1 Output TOML 1.1, where tables can be multi-line. @@ -277,6 +278,7 @@ pub enum OutputFormat { Raw, #[default] Rcl, + Systemd, Toml10, Toml11, YamlStream, @@ -448,6 +450,7 @@ pub fn parse(args: Vec) -> Result<(GlobalOptions, Cmd)> { "json-lines" => OutputFormat::JsonLines, "raw" => OutputFormat::Raw, "rcl" => OutputFormat::Rcl, + "systemd" => OutputFormat::Systemd, // Without version, "toml" defaults to the older version, // because it has wider compatibility. "toml" => OutputFormat::Toml10, @@ -886,7 +889,7 @@ mod test { ); assert_eq!( fail_parse(&["rcl", "eval", "infile", "--format=yamr"]), - "Error: Expected --format to be followed by one of json, json-lines, raw, rcl, toml, toml-1.0, toml-1.1, yaml-stream. See --help for usage.\n" + "Error: Expected --format to be followed by one of json, json-lines, raw, rcl, systemd, toml, toml-1.0, toml-1.1, yaml-stream. See --help for usage.\n" ); assert_eq!( fail_parse(&["rcl", "frobnicate", "infile"]), diff --git a/src/cmd_build.rs b/src/cmd_build.rs index fcf2dc5c..b45f9bb5 100644 --- a/src/cmd_build.rs +++ b/src/cmd_build.rs @@ -75,6 +75,7 @@ fn parse_format(format: &str) -> Option { "json-lines" => OutputFormat::JsonLines, "raw" => OutputFormat::Raw, "rcl" => OutputFormat::Rcl, + "systemd" => OutputFormat::Systemd, "toml" => OutputFormat::Toml10, "toml-1.0" => OutputFormat::Toml10, "toml-1.1" => OutputFormat::Toml11, diff --git a/src/cmd_eval.rs b/src/cmd_eval.rs index 1dffcbfe..5da8fbfe 100644 --- a/src/cmd_eval.rs +++ b/src/cmd_eval.rs @@ -20,6 +20,7 @@ pub fn format_value(format: OutputFormat, value_span: Span, value: &Value) -> Re OutputFormat::JsonLines => crate::fmt_json_lines::format_json_lines(value_span, value)?, OutputFormat::Raw => crate::fmt_raw::format_raw(value_span, value)?, OutputFormat::Rcl => crate::fmt_rcl::format_rcl(value), + OutputFormat::Systemd => todo!("Implement systemd output."), OutputFormat::Toml10 => { crate::fmt_toml::format_toml(TomlVersion::Toml10, value_span, value)? } From e5422b39fd3bf57d62744aeff454d53ee7f8d042 Mon Sep 17 00:00:00 2001 From: Ruud van Asseldonk Date: Thu, 9 Apr 2026 01:07:19 +0200 Subject: [PATCH 02/22] Finish systemd output format, first iteration It doesn't yet handle dicts, which would be useful for Environment=, but we can handle that later. It also doesn't handle repeated sections, which networkd needs, but we can also handle that in a follow-up commit. --- src/cmd_eval.rs | 2 +- src/fmt_systemd.rs | 223 +++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 3 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 src/fmt_systemd.rs diff --git a/src/cmd_eval.rs b/src/cmd_eval.rs index 5da8fbfe..b5f23d7d 100644 --- a/src/cmd_eval.rs +++ b/src/cmd_eval.rs @@ -20,7 +20,7 @@ pub fn format_value(format: OutputFormat, value_span: Span, value: &Value) -> Re OutputFormat::JsonLines => crate::fmt_json_lines::format_json_lines(value_span, value)?, OutputFormat::Raw => crate::fmt_raw::format_raw(value_span, value)?, OutputFormat::Rcl => crate::fmt_rcl::format_rcl(value), - OutputFormat::Systemd => todo!("Implement systemd output."), + OutputFormat::Systemd => crate::fmt_systemd::format_systemd(value_span, value)?, OutputFormat::Toml10 => { crate::fmt_toml::format_toml(TomlVersion::Toml10, value_span, value)? } diff --git a/src/fmt_systemd.rs b/src/fmt_systemd.rs new file mode 100644 index 00000000..abf4e568 --- /dev/null +++ b/src/fmt_systemd.rs @@ -0,0 +1,223 @@ +// RCL -- A reasonable configuration language. +// Copyright 2026 Ruud van Asseldonk + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// A copy of the License has been included in the root of the repository. + +//! Formatter that prints values as systemd units. +//! +//! This formatter is similar to the one in [`crate::fmt_toml`]. + +use std::collections::BTreeMap; + +use crate::error::{IntoError, PathElement, Result}; +use crate::markup::Markup; +use crate::pprint::{concat, Doc}; +use crate::runtime::Value; +use crate::source::Span; +use crate::string::escape_json; + +/// Render a value as systemd unit. +pub fn format_systemd(caller: Span, v: &Value) -> Result { + let mut formatter = Formatter::new(caller); + + match v { + Value::Dict(kv) => formatter.top_level(kv), + _ => formatter.error("To format as systemd unit, the top-level value must be a dict."), + } +} + +/// Helper for formatting values as systemd unit. +/// +/// The formatter tracks the path in the value that we are formatting from, such +/// that we can report the location of an error, in case an error occurs. +struct Formatter { + /// The source location where formatting was triggered from. + caller: Span, + + /// Where we currently are in the value to be formatted. + path: Vec, +} + +impl Formatter { + pub fn new(caller: Span) -> Formatter { + Formatter { + caller, + path: Vec::new(), + } + } + + /// Report an error at the current value path. + fn error(&mut self, message: &'static str) -> Result { + // Steal the path from the formatter and move it into the error. We have + // to leave an empty path in its place. This is fine, because returning + // the error prevents further formatting. + let mut path = Vec::new(); + std::mem::swap(&mut self.path, &mut path); + self.caller.error(message).with_path(path).err() + } + + /// Format a string. + fn string<'a>(&self, s: &str) -> Doc<'a> { + let mut into = String::with_capacity(s.len()); + // Note, json escaping works unmodified for TOML too, the characters that + // need escaping are identical and with the same escape sequences (which + // TOML probably did on purpose). + escape_json(s, &mut into); + concat! { "\"" into "\"" } + } + + /// Format as a key in a key-value pair, or table header (without brackets). + fn key<'a>(&self, ident: &'a str) -> Doc<'a> { + let bytes = ident.as_bytes(); + + if bytes.is_empty() { + return "\"\"".into(); + } + + // If the key is ASCII alphanumeric, underscores, or dashes, then we can + // use it unquoted. Even if it starts + // with a digit. + if bytes + .iter() + .all(|b| b.is_ascii_alphanumeric() || *b == b'_' || *b == b'-') + { + ident.into() + } else { + self.string(ident) + } + } + + /// Format a key, and push it to as path, or return an error on non-strings. + fn push_key<'a>(&mut self, key: &'a Value) -> Result> { + self.path.push(PathElement::Key(key.clone())); + match key { + Value::String(k_str) => Ok(self.key(k_str).with_markup(Markup::Field)), + _ => self.error("To export as TOML, keys must be strings."), + } + } + + /// Format a key-value pair inside a section. + /// + /// If the value is a list or set, we repeat the key. + fn key_value<'a>(&mut self, key: &'a Value, value: &'a Value) -> Result> { + match value { + Value::List(vs) => self.key_values(key, vs.iter()), + Value::Set(vs) => self.key_values(key, vs.iter()), + v => { + let result = concat! { + self.push_key(key)? "=" self.value(v)? Doc::HardBreak + }; + self.path.pop().expect("We pushed the key before."); + Ok(result) + } + } + } + + /// Format a repeated key-value pair inside a section. + fn key_values<'a>( + &mut self, + key: &'a Value, + values: impl Iterator, + ) -> Result> { + let mut parts: Vec> = Vec::new(); + for v in values { + parts.push(self.push_key(key)?); + parts.push("=".into()); + parts.push(self.value(v)?); + parts.push(Doc::HardBreak); + self.path.pop().expect("We pushed the key before."); + } + Ok(Doc::Concat(parts)) + } + + /// Format a space-separated list of values. + fn space_separated<'a>(&mut self, values: impl Iterator) -> Result> { + let mut parts: Vec> = Vec::new(); + for (i, v) in values.enumerate() { + if i > 0 { + // The separator is always a space, we don't allow line breaks. + // TODO: We might allow tall with \. + parts.push(" ".into()); + } + parts.push(self.value(v)?); + } + Ok(Doc::Concat(parts)) + } + + fn value<'a>(&mut self, v: &'a Value) -> Result> { + let result = match v { + Value::Null => Doc::Empty, + Value::Bool(true) => Doc::from("true").with_markup(Markup::Keyword), + Value::Bool(false) => Doc::from("false").with_markup(Markup::Keyword), + Value::Number(d) => Doc::from(d.format()).with_markup(Markup::Number), + // TODO: Quote only if needed. + Value::String(s) => self.string(s).with_markup(Markup::String), + + // For collections, the outer formatter already formats them by + // repeating the key. If we still get here, that's a collection + // inside a collection, and then we format them space-separated. + Value::List(vs) => self.space_separated(vs.iter())?, + Value::Set(vs) => self.space_separated(vs.iter())?, + + // If we get a dict at this level, we can't really decide for the + // user how to format that. E.g. BindPaths= takes colon-separated + // "key-values", Environment= takes fully quoted 'key=value' pairs + // separated by spaces. Probably the latter makes sense. But for + // now we just ban dicts and let the user make strings. + Value::Dict(..) => self + .error("Dicts cannot be exported in systemd units.") + .map_err(|err| { + err.with_help( + "Format key-values as strings first, for example with a comprehension.", + ) + })?, + Value::Function(..) => self.error("Functions cannot be exported in systemd units.")?, + Value::BuiltinFunction(..) => { + self.error("Functions cannot be exported in systemd units.")? + } + Value::BuiltinMethod { .. } => { + self.error("Methods cannot be exported in systemd units.")? + } + }; + Ok(result) + } + + fn top_level<'a>(&mut self, kv: &'a BTreeMap) -> Result> { + let mut result: Vec = Vec::new(); + + for (k, v) in kv { + match v { + // Top-level dicts get formatted as sections. + Value::Dict(section_inner) => { + let section_header = self.push_key(k)?; + + // Separate sections by a blank line. + if !result.is_empty() { + result.push(Doc::HardBreak); + } + + result.push(concat! { "[" section_header "]" }); + result.push(Doc::HardBreak); + + for (inner_k, inner_v) in section_inner.iter() { + result.push(self.key_value(inner_k, inner_v)?); + } + + self.path.pop().expect("We pushed the key before."); + } + + // TODO: It may be a list or set as well, sections can be repeated. + + // Anything else is invalid at this level. + _ => { + self.push_key(k)?; + self.error("Expected a dict, e.g. { WantedBy = \"multi-user.target\" }.")?; + } + } + } + + Ok(Doc::Concat(result)) + } +} diff --git a/src/lib.rs b/src/lib.rs index f259cb69..041a1ff2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -97,6 +97,7 @@ pub mod fmt_json; pub mod fmt_json_lines; pub mod fmt_raw; pub mod fmt_rcl; +pub mod fmt_systemd; pub mod fmt_toml; pub mod fmt_type; pub mod fmt_yaml_stream; From 25b200ef43fc53ed01dfb448237e3a952bed81bf Mon Sep 17 00:00:00 2001 From: Ruud van Asseldonk Date: Sat, 11 Jul 2026 10:57:48 +0200 Subject: [PATCH 03/22] Add initial golden test for systemd output format --- golden/run.py | 4 ++++ golden/systemd/basic_unit.test | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 golden/systemd/basic_unit.test diff --git a/golden/run.py b/golden/run.py index e23de3b0..ec09d03e 100755 --- a/golden/run.py +++ b/golden/run.py @@ -102,6 +102,7 @@ def test_one(fname: str, fname_friendly: str, *, rewrite_output: bool) -> Option case "error" | "types": cmd = ["eval"] + # TODO: Move into the regular json and raw directories? case "error_json": cmd = ["eval", "--format=json"] @@ -126,6 +127,9 @@ def test_one(fname: str, fname_friendly: str, *, rewrite_output: bool) -> Option case "rcl": cmd = ["eval", "--format=rcl"] + case "systemd": + cmd = ["eval", "--format=systemd"] + case "toml_10": cmd = ["eval", "--format=toml-1.0"] # For TOML, when the test case is not an error, we additionally test diff --git a/golden/systemd/basic_unit.test b/golden/systemd/basic_unit.test new file mode 100644 index 00000000..49a57367 --- /dev/null +++ b/golden/systemd/basic_unit.test @@ -0,0 +1,23 @@ +{ + Unit = { + Description = "Simple test unit", + }, + Service = { + Type = "simple", + ExecStart = "/usr/bin/hello", + }, + Install = { + WantedBy = "multi-user.target", + }, +} + +# output: +[Install] +WantedBy="multi-user.target" + +[Service] +ExecStart="/usr/bin/hello" +Type="simple" + +[Unit] +Description="Simple test unit" From 47d8972849e6343d6c088057a95fbdb97aab3e78 Mon Sep 17 00:00:00 2001 From: Ruud van Asseldonk Date: Sat, 11 Jul 2026 11:49:34 +0200 Subject: [PATCH 04/22] Implement systemd string quoting Because I want the rendered unit to look like one I would write by hand, most strings unquoted. Maybe that's the same pitfall as yaml, but it's not as prevalent in systemd, and anyway this is about output only. --- golden/systemd/basic_unit.test | 8 ++--- src/fmt_systemd.rs | 61 ++++++++++++++++++++++++++++------ 2 files changed, 55 insertions(+), 14 deletions(-) diff --git a/golden/systemd/basic_unit.test b/golden/systemd/basic_unit.test index 49a57367..f57959f4 100644 --- a/golden/systemd/basic_unit.test +++ b/golden/systemd/basic_unit.test @@ -13,11 +13,11 @@ # output: [Install] -WantedBy="multi-user.target" +WantedBy=multi-user.target [Service] -ExecStart="/usr/bin/hello" -Type="simple" +ExecStart=/usr/bin/hello +Type=simple [Unit] -Description="Simple test unit" +Description=Simple test unit diff --git a/src/fmt_systemd.rs b/src/fmt_systemd.rs index abf4e568..a3007c27 100644 --- a/src/fmt_systemd.rs +++ b/src/fmt_systemd.rs @@ -16,7 +16,6 @@ use crate::markup::Markup; use crate::pprint::{concat, Doc}; use crate::runtime::Value; use crate::source::Span; -use crate::string::escape_json; /// Render a value as systemd unit. pub fn format_systemd(caller: Span, v: &Value) -> Result { @@ -58,14 +57,57 @@ impl Formatter { self.caller.error(message).with_path(path).err() } - /// Format a string. - fn string<'a>(&self, s: &str) -> Doc<'a> { - let mut into = String::with_capacity(s.len()); - // Note, json escaping works unmodified for TOML too, the characters that - // need escaping are identical and with the same escape sequences (which - // TOML probably did on purpose). - escape_json(s, &mut into); - concat! { "\"" into "\"" } + /// Format a string, quoted when needed. + /// + /// Similar to [`crate::string::escape_json`], but specialized for systemd. + /// See also . + fn string<'a>(&self, str: &str) -> Doc<'a> { + use std::fmt::Write; + + let mut into = String::with_capacity(str.len()); + let mut needs_quotes = false; + + for ch in str.chars() { + let mut escaped = true; + match ch { + '\x07' => into.push_str(r#"\a"#), + '\x08' => into.push_str(r#"\b"#), + '\x0c' => into.push_str(r#"\f"#), + '\n' => into.push_str(r#"\n"#), + '\r' => into.push_str(r#"\r"#), + '\t' => into.push_str(r#"\t"#), + '\x0b' => into.push_str(r#"\v"#), + '\\' => into.push_str(r#"\\"#), + '\'' => into.push_str(r#"\'"#), + '\"' => into.push_str(r#"\""#), + ch if ch.is_ascii_control() => write!(into, "\\x{:02x}", ch as u32) + .expect("Writing into &mut String does not fail."), + ch => { + escaped = false; + into.push(ch); + } + } + needs_quotes |= escaped; + } + + // If the string starts or ends in whitespace, also quote it, otherwise + // the `Key = value` syntax eats it (at the start), or it's not obvious + // that the space is there (at the end). + needs_quotes |= str + .chars() + .next() + .map(|ch| ch.is_ascii_whitespace()) + .unwrap_or(true); + needs_quotes |= str + .chars() + .last() + .map(|ch| ch.is_ascii_whitespace()) + .unwrap_or(true); + + match needs_quotes { + true => concat! { "\"" into "\"" }, + false => into.into(), + } } /// Format as a key in a key-value pair, or table header (without brackets). @@ -152,7 +194,6 @@ impl Formatter { Value::Bool(true) => Doc::from("true").with_markup(Markup::Keyword), Value::Bool(false) => Doc::from("false").with_markup(Markup::Keyword), Value::Number(d) => Doc::from(d.format()).with_markup(Markup::Number), - // TODO: Quote only if needed. Value::String(s) => self.string(s).with_markup(Markup::String), // For collections, the outer formatter already formats them by From a4ae8e9c897bacdd68f888e67f6b8ac7113e8fbc Mon Sep 17 00:00:00 2001 From: Ruud van Asseldonk Date: Sat, 11 Jul 2026 12:08:13 +0200 Subject: [PATCH 05/22] Test more features of the systemd output format --- golden/systemd/lists.test | 23 ++++++++++++++++++++++ golden/systemd/non_string_values.test | 28 +++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 golden/systemd/lists.test create mode 100644 golden/systemd/non_string_values.test diff --git a/golden/systemd/lists.test b/golden/systemd/lists.test new file mode 100644 index 00000000..47152cac --- /dev/null +++ b/golden/systemd/lists.test @@ -0,0 +1,23 @@ +{ + Service = { + // A list at the key level repeats the key in the output. + ExecStart = [ + "/usr/bin/hello --check-config", + "/usr/bin/hello --init", + + // A second level of nesting creates a space-separated list of values. + ["/usr/bin/hello", "--daemon", "--config", "/etc/hello/hello.cfg"], + ], + + // Even if the key is not repeated, a nested list triggers space-separation. + // This applies to sets as well. + Environment = [{"LANG=en_US.UTF-8", "HOME=/home/user"}], + }, +} + +# output: +[Service] +Environment=HOME=/home/user LANG=en_US.UTF-8 +ExecStart=/usr/bin/hello --check-config +ExecStart=/usr/bin/hello --init +ExecStart=/usr/bin/hello --daemon --config /etc/hello/hello.cfg diff --git a/golden/systemd/non_string_values.test b/golden/systemd/non_string_values.test new file mode 100644 index 00000000..9a621ae8 --- /dev/null +++ b/golden/systemd/non_string_values.test @@ -0,0 +1,28 @@ +{ + Unit = { + After = {"network-online.target", "postgresql.service"}, + }, + Service = { + RemainAfterExit = true, + RestartSec = 1.5, + RestartSteps = 5, + Environment = [ + // Null turns into an empty assignment, which is important for overrides + // where empty assignments clear the previous value, instead of appending. + null, + "FOO=bar", + ], + }, +} + +# output: +[Service] +Environment= +Environment=FOO=bar +RemainAfterExit=true +RestartSec=1.5 +RestartSteps=5 + +[Unit] +After=network-online.target +After=postgresql.service From bf570ffa5fdcc4c25563ec68d73bb224aebc66d5 Mon Sep 17 00:00:00 2001 From: Ruud van Asseldonk Date: Sat, 11 Jul 2026 14:17:09 +0200 Subject: [PATCH 06/22] Enable repeating sections in systemd output --- golden/systemd/lists.test | 14 ++++++++ src/fmt_systemd.rs | 70 +++++++++++++++++++++++++++------------ 2 files changed, 62 insertions(+), 22 deletions(-) diff --git a/golden/systemd/lists.test b/golden/systemd/lists.test index 47152cac..58b2d6b6 100644 --- a/golden/systemd/lists.test +++ b/golden/systemd/lists.test @@ -13,6 +13,12 @@ // This applies to sets as well. Environment = [{"LANG=en_US.UTF-8", "HOME=/home/user"}], }, + + // A top-level section may be repeated as well. + WireGuardPeer = [ + { AllowedIPs = "10.0.0.1/32", PublicKey = "ceenqsWnNs7cH29WMopAnNPqqkRb9M5R/5DrnSY6Swk=" }, + { AllowedIPs = "10.0.0.2/32", PublicKey = "YiBg9Q5aUlRx5h2D24xJom7+EQzIQazin5LTuKIKtxY=" }, + ] } # output: @@ -21,3 +27,11 @@ Environment=HOME=/home/user LANG=en_US.UTF-8 ExecStart=/usr/bin/hello --check-config ExecStart=/usr/bin/hello --init ExecStart=/usr/bin/hello --daemon --config /etc/hello/hello.cfg + +[WireGuardPeer] +AllowedIPs=10.0.0.1/32 +PublicKey=ceenqsWnNs7cH29WMopAnNPqqkRb9M5R/5DrnSY6Swk= + +[WireGuardPeer] +AllowedIPs=10.0.0.2/32 +PublicKey=YiBg9Q5aUlRx5h2D24xJom7+EQzIQazin5LTuKIKtxY= diff --git a/src/fmt_systemd.rs b/src/fmt_systemd.rs index a3007c27..4def1f86 100644 --- a/src/fmt_systemd.rs +++ b/src/fmt_systemd.rs @@ -225,37 +225,63 @@ impl Formatter { Ok(result) } + fn section<'a>( + &mut self, + section_header: &'a Value, + inner: &'a Value, + result: &mut Vec>, + ) -> Result<()> { + let section_header = self.push_key(section_header)?; + + // Separate sections by a blank line. + if !result.is_empty() { + result.push(Doc::HardBreak); + } + + result.push(concat! { "[" section_header "]" }); + result.push(Doc::HardBreak); + + match inner { + // When we format a section, the value must be a dict of `Key=value` pairs. + Value::Dict(kv) => { + for (inner_k, inner_v) in kv.iter() { + result.push(self.key_value(inner_k, inner_v)?); + } + } + + // Anything else is invalid at this level. + _ => self.error("Expected a dict, e.g. { WantedBy = \"multi-user.target\" }.")?, + } + + self.path.pop().expect("We pushed the key before."); + + Ok(()) + } + fn top_level<'a>(&mut self, kv: &'a BTreeMap) -> Result> { let mut result: Vec = Vec::new(); for (k, v) in kv { match v { - // Top-level dicts get formatted as sections. - Value::Dict(section_inner) => { - let section_header = self.push_key(k)?; - - // Separate sections by a blank line. - if !result.is_empty() { - result.push(Doc::HardBreak); + // For a list or set, we repeat the entire section. + Value::List(kvs) => { + for (i, kv) in kvs.iter().enumerate() { + self.path.push(PathElement::Index(i)); + self.section(k, kv, &mut result)?; + self.path.pop(); } - - result.push(concat! { "[" section_header "]" }); - result.push(Doc::HardBreak); - - for (inner_k, inner_v) in section_inner.iter() { - result.push(self.key_value(inner_k, inner_v)?); + } + Value::Set(kvs) => { + for (i, kv) in kvs.iter().enumerate() { + self.path.push(PathElement::Index(i)); + self.section(k, kv, &mut result)?; + self.path.pop(); } - - self.path.pop().expect("We pushed the key before."); } - // TODO: It may be a list or set as well, sections can be repeated. - - // Anything else is invalid at this level. - _ => { - self.push_key(k)?; - self.error("Expected a dict, e.g. { WantedBy = \"multi-user.target\" }.")?; - } + // Anything else must be a dict, but we match that inside the + // `section` method. + _ => self.section(k, v, &mut result)?, } } From 9136b51dd532689a8088b050da47cac88c050f95 Mon Sep 17 00:00:00 2001 From: Ruud van Asseldonk Date: Sat, 11 Jul 2026 14:44:38 +0200 Subject: [PATCH 07/22] Escape systemd strings when they contain spaces It does make this format less ergonomic to use, since now you need to add more layers of lists, and the output can be less pretty, but I'm afraid this is required for correctness. --- golden/systemd/basic_unit.test | 2 +- golden/systemd/lists.test | 7 +++--- golden/systemd/non_string_values.test | 2 ++ golden/systemd/string_escapes.test | 36 +++++++++++++++++++++++++++ src/fmt_systemd.rs | 30 +++++++++++----------- 5 files changed, 56 insertions(+), 21 deletions(-) create mode 100644 golden/systemd/string_escapes.test diff --git a/golden/systemd/basic_unit.test b/golden/systemd/basic_unit.test index f57959f4..26962c8f 100644 --- a/golden/systemd/basic_unit.test +++ b/golden/systemd/basic_unit.test @@ -20,4 +20,4 @@ ExecStart=/usr/bin/hello Type=simple [Unit] -Description=Simple test unit +Description="Simple test unit" diff --git a/golden/systemd/lists.test b/golden/systemd/lists.test index 58b2d6b6..f0e6d537 100644 --- a/golden/systemd/lists.test +++ b/golden/systemd/lists.test @@ -1,11 +1,10 @@ { Service = { // A list at the key level repeats the key in the output. + // A second level of nesting creates a space-separated list of values. ExecStart = [ - "/usr/bin/hello --check-config", - "/usr/bin/hello --init", - - // A second level of nesting creates a space-separated list of values. + ["/usr/bin/hello", "--check-config"], + ["/usr/bin/hello", "--init"], ["/usr/bin/hello", "--daemon", "--config", "/etc/hello/hello.cfg"], ], diff --git a/golden/systemd/non_string_values.test b/golden/systemd/non_string_values.test index 9a621ae8..237aafe6 100644 --- a/golden/systemd/non_string_values.test +++ b/golden/systemd/non_string_values.test @@ -4,6 +4,7 @@ }, Service = { RemainAfterExit = true, + NonBlocking = false, RestartSec = 1.5, RestartSteps = 5, Environment = [ @@ -19,6 +20,7 @@ [Service] Environment= Environment=FOO=bar +NonBlocking=false RemainAfterExit=true RestartSec=1.5 RestartSteps=5 diff --git a/golden/systemd/string_escapes.test b/golden/systemd/string_escapes.test new file mode 100644 index 00000000..d16ce8a8 --- /dev/null +++ b/golden/systemd/string_escapes.test @@ -0,0 +1,36 @@ +{ + Service = { + ExecStart = [ + [ + "/usr/bin/sh", + "-c", + """ + echo 'hello' "$USER" + """ + ], + [ + "/usr/bin/sed", + "--in-place", + "s/\t/ /g", + "src.rs", + ], + [ + "/usr/bin/grep", + "\u{06}\u{07}\u{08}\u{0b}\u{0c}\r", + ], + [ + "/usr/bin/echo", + " space before", + "space inside", + "space after ", + ], + ], + } +} + +# output: +[Service] +ExecStart=/usr/bin/sh -c "echo 'hello' \"$USER\"\n" +ExecStart=/usr/bin/sed --in-place "s/\t/ /g" src.rs +ExecStart=/usr/bin/grep "\x06\a\b\v\f\r" +ExecStart=/usr/bin/echo " space before" "space inside" "space after " diff --git a/src/fmt_systemd.rs b/src/fmt_systemd.rs index 4def1f86..3015e625 100644 --- a/src/fmt_systemd.rs +++ b/src/fmt_systemd.rs @@ -78,7 +78,10 @@ impl Formatter { '\t' => into.push_str(r#"\t"#), '\x0b' => into.push_str(r#"\v"#), '\\' => into.push_str(r#"\\"#), - '\'' => into.push_str(r#"\'"#), + // Single quotes do trigger surrounding quotes, but because we + // always quote in double quotes, the single quote does not need + // to be escaped. + '\'' => into.push('\''), '\"' => into.push_str(r#"\""#), ch if ch.is_ascii_control() => write!(into, "\\x{:02x}", ch as u32) .expect("Writing into &mut String does not fail."), @@ -87,22 +90,17 @@ impl Formatter { into.push(ch); } } - needs_quotes |= escaped; - } - // If the string starts or ends in whitespace, also quote it, otherwise - // the `Key = value` syntax eats it (at the start), or it's not obvious - // that the space is there (at the end). - needs_quotes |= str - .chars() - .next() - .map(|ch| ch.is_ascii_whitespace()) - .unwrap_or(true); - needs_quotes |= str - .chars() - .last() - .map(|ch| ch.is_ascii_whitespace()) - .unwrap_or(true); + // If the value contains whitespace, we quote it. Without this, it + // would not be possible to e.g. have whitespace at the start of + // values. It does mean we err on the side of quoting, for example + // in Description=, the spaces don't really matter, and it would + // read easier if the description were not quoted. But for + // ExecStart=, being able to specify the exact argv matters: `"a b"` + // is not the same as `a b`. We're not going to make the behavior + // dependent on the key, so quote conservatively. + needs_quotes |= escaped || ch.is_ascii_whitespace(); + } match needs_quotes { true => concat! { "\"" into "\"" }, From 739e6de9c7ce0403bdd493530d6ec768130d6183 Mon Sep 17 00:00:00 2001 From: Ruud van Asseldonk Date: Sat, 11 Jul 2026 14:58:27 +0200 Subject: [PATCH 08/22] Add support for line wrapping in systemd output So long command lines remain somewhat readable in the generated files. --- golden/systemd/tall_list.test | 20 ++++++++++++++++++++ src/fmt_systemd.rs | 27 +++++++++++++++++++-------- 2 files changed, 39 insertions(+), 8 deletions(-) create mode 100644 golden/systemd/tall_list.test diff --git a/golden/systemd/tall_list.test b/golden/systemd/tall_list.test new file mode 100644 index 00000000..88119b43 --- /dev/null +++ b/golden/systemd/tall_list.test @@ -0,0 +1,20 @@ +{ + Service = { + // A long space-separated list gets broken over multiple lines + // with \ for continuation. + let cmd = [ + "/usr/bin/frobnicate", + "--add-widget=d8c981b7-e7a6-40e9-aaae-f812f756459a", + "--add-widget=a11579cd-12cf-4f56-87b3-265823a40b6b", + "--add-widget=acb6e064-68a2-4d00-9946-8dc160e6c02c", + ]; + ExecStart = [cmd], + } +} + +# output: +[Service] +ExecStart=/usr/bin/frobnicate\ + --add-widget=d8c981b7-e7a6-40e9-aaae-f812f756459a\ + --add-widget=a11579cd-12cf-4f56-87b3-265823a40b6b\ + --add-widget=acb6e064-68a2-4d00-9946-8dc160e6c02c diff --git a/src/fmt_systemd.rs b/src/fmt_systemd.rs index 3015e625..8fea744d 100644 --- a/src/fmt_systemd.rs +++ b/src/fmt_systemd.rs @@ -13,7 +13,7 @@ use std::collections::BTreeMap; use crate::error::{IntoError, PathElement, Result}; use crate::markup::Markup; -use crate::pprint::{concat, Doc}; +use crate::pprint::{concat, group, indent, Doc}; use crate::runtime::Value; use crate::source::Span; @@ -173,17 +173,28 @@ impl Formatter { } /// Format a space-separated list of values. + /// + /// In wide mode, the separator is just spaces. In tall mode, the spaces + /// turn into line breaks preceded by a backslash, which in systemd are + /// equivalent to a space. fn space_separated<'a>(&mut self, values: impl Iterator) -> Result> { - let mut parts: Vec> = Vec::new(); + let mut head = None; + let mut tail: Vec> = Vec::new(); for (i, v) in values.enumerate() { - if i > 0 { - // The separator is always a space, we don't allow line breaks. - // TODO: We might allow tall with \. - parts.push(" ".into()); + match i { + 0 => head = Some(self.value(v)?), + _ => { + tail.push(Doc::tall("\\")); + tail.push(Doc::Sep); + tail.push(self.value(v)?); + } } - parts.push(self.value(v)?); } - Ok(Doc::Concat(parts)) + let result = group! { + head + indent! { Doc::Concat(tail) } + }; + Ok(result) } fn value<'a>(&mut self, v: &'a Value) -> Result> { From 3fc1f9f21a5abf0b8661d92f294e6a5060de4ec6 Mon Sep 17 00:00:00 2001 From: Ruud van Asseldonk Date: Sat, 11 Jul 2026 16:17:32 +0200 Subject: [PATCH 09/22] Add test cases for errors in systemd output format Also fix an error message that was still referring to TOML. --- golden/systemd/error_key_not_string_section.test | 16 ++++++++++++++++ .../systemd/error_key_not_string_top_level.test | 12 ++++++++++++ golden/systemd/error_not_dict_section.test | 12 ++++++++++++ golden/systemd/error_not_dict_section_list.test | 13 +++++++++++++ golden/systemd/error_not_dict_section_set.test | 13 +++++++++++++ golden/systemd/error_not_dict_top_level.test | 8 ++++++++ golden/systemd/error_value_function.test | 15 +++++++++++++++ golden/systemd/error_value_method.test | 15 +++++++++++++++ src/fmt_systemd.rs | 5 ++--- 9 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 golden/systemd/error_key_not_string_section.test create mode 100644 golden/systemd/error_key_not_string_top_level.test create mode 100644 golden/systemd/error_not_dict_section.test create mode 100644 golden/systemd/error_not_dict_section_list.test create mode 100644 golden/systemd/error_not_dict_section_set.test create mode 100644 golden/systemd/error_not_dict_top_level.test create mode 100644 golden/systemd/error_value_function.test create mode 100644 golden/systemd/error_value_method.test diff --git a/golden/systemd/error_key_not_string_section.test b/golden/systemd/error_key_not_string_section.test new file mode 100644 index 00000000..4a6dc408 --- /dev/null +++ b/golden/systemd/error_key_not_string_section.test @@ -0,0 +1,16 @@ +{ + Unit = { + Description = "This is still fine.", + 1: "But this is not.", + }, +} + +# output: +stdin:1:1 + ╷ +1 │ { + ╵ ^ +in value +at key "Unit" +at key 1 +Error: To export as systemd, keys must be strings. diff --git a/golden/systemd/error_key_not_string_top_level.test b/golden/systemd/error_key_not_string_top_level.test new file mode 100644 index 00000000..d8c4a93e --- /dev/null +++ b/golden/systemd/error_key_not_string_top_level.test @@ -0,0 +1,12 @@ +{ + 1: { Description = "That's an error." }, +} + +# output: +stdin:1:1 + ╷ +1 │ { + ╵ ^ +in value +at key 1 +Error: To export as systemd, keys must be strings. diff --git a/golden/systemd/error_not_dict_section.test b/golden/systemd/error_not_dict_section.test new file mode 100644 index 00000000..9974d147 --- /dev/null +++ b/golden/systemd/error_not_dict_section.test @@ -0,0 +1,12 @@ +{ + Service = true, +} + +# output: +stdin:1:1 + ╷ +1 │ { + ╵ ^ +in value +at key "Service" +Error: Expected a dict, e.g. { WantedBy = "multi-user.target" }. diff --git a/golden/systemd/error_not_dict_section_list.test b/golden/systemd/error_not_dict_section_list.test new file mode 100644 index 00000000..18b36b76 --- /dev/null +++ b/golden/systemd/error_not_dict_section_list.test @@ -0,0 +1,13 @@ +{ + Service = [true], +} + +# output: +stdin:1:1 + ╷ +1 │ { + ╵ ^ +in value +at index 0 +at key "Service" +Error: Expected a dict, e.g. { WantedBy = "multi-user.target" }. diff --git a/golden/systemd/error_not_dict_section_set.test b/golden/systemd/error_not_dict_section_set.test new file mode 100644 index 00000000..249da350 --- /dev/null +++ b/golden/systemd/error_not_dict_section_set.test @@ -0,0 +1,13 @@ +{ + Service = {true}, +} + +# output: +stdin:1:1 + ╷ +1 │ { + ╵ ^ +in value +at index 0 +at key "Service" +Error: Expected a dict, e.g. { WantedBy = "multi-user.target" }. diff --git a/golden/systemd/error_not_dict_top_level.test b/golden/systemd/error_not_dict_top_level.test new file mode 100644 index 00000000..8ea1e3ee --- /dev/null +++ b/golden/systemd/error_not_dict_top_level.test @@ -0,0 +1,8 @@ +null + +# output: +stdin:1:1 + ╷ +1 │ null + ╵ ^~~~ +Error: To format as systemd unit, the top-level value must be a dict. diff --git a/golden/systemd/error_value_function.test b/golden/systemd/error_value_function.test new file mode 100644 index 00000000..cbf80cd0 --- /dev/null +++ b/golden/systemd/error_value_function.test @@ -0,0 +1,15 @@ +{ + Service = { + ExecStart = arg => ["/usr/bin/env", arg], + } +} + +# output: +stdin:1:1 + ╷ +1 │ { + ╵ ^ +in value +at key "Service" +at key "ExecStart" +Error: Functions cannot be exported in systemd units. diff --git a/golden/systemd/error_value_method.test b/golden/systemd/error_value_method.test new file mode 100644 index 00000000..3bdcbe78 --- /dev/null +++ b/golden/systemd/error_value_method.test @@ -0,0 +1,15 @@ +{ + Service = { + ExecStart = "abc".len + } +} + +# output: +stdin:1:1 + ╷ +1 │ { + ╵ ^ +in value +at key "Service" +at key "ExecStart" +Error: Methods cannot be exported in systemd units. diff --git a/src/fmt_systemd.rs b/src/fmt_systemd.rs index 8fea744d..ce03a1cb 100644 --- a/src/fmt_systemd.rs +++ b/src/fmt_systemd.rs @@ -134,7 +134,7 @@ impl Formatter { self.path.push(PathElement::Key(key.clone())); match key { Value::String(k_str) => Ok(self.key(k_str).with_markup(Markup::Field)), - _ => self.error("To export as TOML, keys must be strings."), + _ => self.error("To export as systemd, keys must be strings."), } } @@ -223,8 +223,7 @@ impl Formatter { "Format key-values as strings first, for example with a comprehension.", ) })?, - Value::Function(..) => self.error("Functions cannot be exported in systemd units.")?, - Value::BuiltinFunction(..) => { + Value::Function(..) | Value::BuiltinFunction(..) => { self.error("Functions cannot be exported in systemd units.")? } Value::BuiltinMethod { .. } => { From 202ec98ecd5f1ce443f931c8907bf7a3b40f248d Mon Sep 17 00:00:00 2001 From: Ruud van Asseldonk Date: Sat, 11 Jul 2026 16:44:28 +0200 Subject: [PATCH 10/22] Validate keys in systemd formatter Systemd behaves quite differently than TOML here. More ad-hoc, unfortunately. --- golden/systemd/error_key_comment.test | 15 +++++++++++ golden/systemd/error_key_empty.test | 16 +++++++++++ golden/systemd/error_key_space.test | 17 ++++++++++++ golden/systemd/string_escapes.test | 4 +-- golden/systemd/weird_keys.test | 9 +++++++ src/fmt_systemd.rs | 38 +++++++++++++++------------ 6 files changed, 80 insertions(+), 19 deletions(-) create mode 100644 golden/systemd/error_key_comment.test create mode 100644 golden/systemd/error_key_empty.test create mode 100644 golden/systemd/error_key_space.test create mode 100644 golden/systemd/weird_keys.test diff --git a/golden/systemd/error_key_comment.test b/golden/systemd/error_key_comment.test new file mode 100644 index 00000000..cecdbcc5 --- /dev/null +++ b/golden/systemd/error_key_comment.test @@ -0,0 +1,15 @@ +{ + Unit = { + "; Would be comment": "Therefore disallowed.", + }, +} + +# output: +stdin:1:1 + ╷ +1 │ { + ╵ ^ +in value +at key "Unit" +at key "; Would be comment" +Error: To export as systemd, keys cannot start with the comment characters '#' and ';'. diff --git a/golden/systemd/error_key_empty.test b/golden/systemd/error_key_empty.test new file mode 100644 index 00000000..f724d9f6 --- /dev/null +++ b/golden/systemd/error_key_empty.test @@ -0,0 +1,16 @@ +{ + Unit = { + "Description": "Empty keys are invalid!", + "": "This is not allowed.", + }, +} + +# output: +stdin:1:1 + ╷ +1 │ { + ╵ ^ +in value +at key "Unit" +at key "" +Error: To export as systemd, keys cannot be empty. diff --git a/golden/systemd/error_key_space.test b/golden/systemd/error_key_space.test new file mode 100644 index 00000000..710665c8 --- /dev/null +++ b/golden/systemd/error_key_space.test @@ -0,0 +1,17 @@ +{ + Unit = { + "Description": "Leading whitespace is invalid", + "Inner is OK": "This is fine actually", + " Leading is not": "But this is not.", + }, +} + +# output: +stdin:1:1 + ╷ +1 │ { + ╵ ^ +in value +at key "Unit" +at key " Leading is not" +Error: To export as systemd, keys must not contain surrounding whitespace. diff --git a/golden/systemd/string_escapes.test b/golden/systemd/string_escapes.test index d16ce8a8..fe53ccc5 100644 --- a/golden/systemd/string_escapes.test +++ b/golden/systemd/string_escapes.test @@ -5,7 +5,7 @@ "/usr/bin/sh", "-c", """ - echo 'hello' "$USER" + echo 'hello' "$USER" 'Backslash: \\' """ ], [ @@ -30,7 +30,7 @@ # output: [Service] -ExecStart=/usr/bin/sh -c "echo 'hello' \"$USER\"\n" +ExecStart=/usr/bin/sh -c "echo 'hello' \"$USER\" 'Backslash: \\'\n" ExecStart=/usr/bin/sed --in-place "s/\t/ /g" src.rs ExecStart=/usr/bin/grep "\x06\a\b\v\f\r" ExecStart=/usr/bin/echo " space before" "space inside" "space after " diff --git a/golden/systemd/weird_keys.test b/golden/systemd/weird_keys.test new file mode 100644 index 00000000..bcc2963b --- /dev/null +++ b/golden/systemd/weird_keys.test @@ -0,0 +1,9 @@ +{ + "Section With Spaces": { + "Key With Spaces": "Is allowed.", + }, +} + +# output: +[Section With Spaces] +Key With Spaces="Is allowed." diff --git a/src/fmt_systemd.rs b/src/fmt_systemd.rs index ce03a1cb..593a2de6 100644 --- a/src/fmt_systemd.rs +++ b/src/fmt_systemd.rs @@ -7,7 +7,9 @@ //! Formatter that prints values as systemd units. //! -//! This formatter is similar to the one in [`crate::fmt_toml`]. +//! This formatter is originally based on the one in [`crate::fmt_toml`], but +//! although the formats are superficially similar, both ini-like, the systemd +//! format is far less regular, so the formatter ended up quite differently. use std::collections::BTreeMap; @@ -108,32 +110,34 @@ impl Formatter { } } - /// Format as a key in a key-value pair, or table header (without brackets). - fn key<'a>(&self, ident: &'a str) -> Doc<'a> { - let bytes = ident.as_bytes(); + /// Validate a key or section header. + fn key<'a>(&mut self, ident: &'a str) -> Result> { + if ident.is_empty() { + return self.error("To export as systemd, keys cannot be empty."); + } - if bytes.is_empty() { - return "\"\"".into(); + // Systemd syntax ignores space between the key and the `=`. I'm not + // actually sure about what it does for leading whitespace, and inside + // section headings, but let's just ban this to be sure. + if ident != ident.trim() { + return self + .error("To export as systemd, keys must not contain surrounding whitespace."); } - // If the key is ASCII alphanumeric, underscores, or dashes, then we can - // use it unquoted. Even if it starts - // with a digit. - if bytes - .iter() - .all(|b| b.is_ascii_alphanumeric() || *b == b'_' || *b == b'-') - { - ident.into() - } else { - self.string(ident) + if ident.starts_with('#') || ident.starts_with(';') { + return self.error( + "To export as systemd, keys cannot start with the comment characters '#' and ';'.", + ); } + + Ok(ident.into()) } /// Format a key, and push it to as path, or return an error on non-strings. fn push_key<'a>(&mut self, key: &'a Value) -> Result> { self.path.push(PathElement::Key(key.clone())); match key { - Value::String(k_str) => Ok(self.key(k_str).with_markup(Markup::Field)), + Value::String(k_str) => Ok(self.key(k_str)?.with_markup(Markup::Field)), _ => self.error("To export as systemd, keys must be strings."), } } From 7b58860b7651ddf1824c9c0c3c8048cf68803975 Mon Sep 17 00:00:00 2001 From: Ruud van Asseldonk Date: Sat, 11 Jul 2026 16:54:13 +0200 Subject: [PATCH 11/22] Add more tests for full coverage of systemd format --- golden/systemd/error_value_dict.test | 20 ++++++++++++++++++++ golden/systemd/lists.test | 14 +++++++++++++- src/fmt_systemd.rs | 4 ++-- 3 files changed, 35 insertions(+), 3 deletions(-) create mode 100644 golden/systemd/error_value_dict.test diff --git a/golden/systemd/error_value_dict.test b/golden/systemd/error_value_dict.test new file mode 100644 index 00000000..1b75385e --- /dev/null +++ b/golden/systemd/error_value_dict.test @@ -0,0 +1,20 @@ +{ + Service = { + Environment = { + PORT = "9001", + LOG_LEVEL = "debug", + } + } +} + +# output: +stdin:1:1 + ╷ +1 │ { + ╵ ^ +in value +at key "Service" +at key "Environment" +Error: Dict values cannot be exported in systemd units. + +Help: Format key-values as strings first, for example with a list comprehension. diff --git a/golden/systemd/lists.test b/golden/systemd/lists.test index f0e6d537..a069e935 100644 --- a/golden/systemd/lists.test +++ b/golden/systemd/lists.test @@ -17,10 +17,22 @@ WireGuardPeer = [ { AllowedIPs = "10.0.0.1/32", PublicKey = "ceenqsWnNs7cH29WMopAnNPqqkRb9M5R/5DrnSY6Swk=" }, { AllowedIPs = "10.0.0.2/32", PublicKey = "YiBg9Q5aUlRx5h2D24xJom7+EQzIQazin5LTuKIKtxY=" }, - ] + ], + + // Set behaves like a list there too. + Match = { + { Name = "wlan0" }, + { Name = "enp5s0u2u4" }, + }, } # output: +[Match] +Name=enp5s0u2u4 + +[Match] +Name=wlan0 + [Service] Environment=HOME=/home/user LANG=en_US.UTF-8 ExecStart=/usr/bin/hello --check-config diff --git a/src/fmt_systemd.rs b/src/fmt_systemd.rs index 593a2de6..b29886f1 100644 --- a/src/fmt_systemd.rs +++ b/src/fmt_systemd.rs @@ -221,10 +221,10 @@ impl Formatter { // separated by spaces. Probably the latter makes sense. But for // now we just ban dicts and let the user make strings. Value::Dict(..) => self - .error("Dicts cannot be exported in systemd units.") + .error("Dict values cannot be exported in systemd units.") .map_err(|err| { err.with_help( - "Format key-values as strings first, for example with a comprehension.", + "Format key-values as strings first, for example with a list comprehension.", ) })?, Value::Function(..) | Value::BuiltinFunction(..) => { From 7f01637d6b1df234e4148bc92c6eadec457c03c0 Mon Sep 17 00:00:00 2001 From: Ruud van Asseldonk Date: Sat, 11 Jul 2026 17:51:58 +0200 Subject: [PATCH 12/22] Add a dedicated output format documentation page Until now the output formats were relatively straightforward, but the systemd output format is going to be a bit more involved, we need a place to document it. --- docs/output_formats.md | 148 +++++++++++++++++++++++++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 149 insertions(+) create mode 100644 docs/output_formats.md diff --git a/docs/output_formats.md b/docs/output_formats.md new file mode 100644 index 00000000..863c0df4 --- /dev/null +++ b/docs/output_formats.md @@ -0,0 +1,148 @@ +# Output formats + +RCL can print output in the formats below. The format can be selected with +[`--format`](rcl_evaluate.md#-f-format-format) on the command line, and with +[`format`](rcl_build.md#format) in build files. +The format names are written lowercase. + +## json + +Output pretty-printed JSON. Sets become lists, functions are not +supported. Because RCL is a superset of JSON, every +JSON value maps to itself. Note that YAML is a +superset of JSON, so this format is appropriate for generating +configuration for tools that accept YAML, such as Kubernetes and +GitHub Actions. + +```rcl +{ + series = "Nexus-6", + instances = {"Roy Batty", "Leon Kowalski"}, +} +``` +Formats as: +``` +{ + "instances": ["Leon Kowalski", "Roy Batty"], + "series": "Nexus-6" +} +``` + +> **Tip:** You can quickly select JSON output on the command line +> with the [`je`](rcl_evaluate.md) and [`jq`](rcl_query.md) shorthands. + +## json-lines + +If the document is a list, output every element as a JSON value on +its own line, consistent with the +JSON lines format. +Top-level values other than lists are not valid for this format. + +```rcl +[ + { name = "Roy Batty", serial = "N6MAA10816" }, + { name = "Leon Kowalski", serial = "N6MAC41717" }, +] +``` +Formats as: +``` +{"name": "Roy Batty", "serial": "N6MAA10816"} +{"name": "Leon Kowalski", "serial": "N6MAC41717"} +``` + +## raw + +If the document is a string, output the string itself. If the document is a list +or set of strings, output each string on its own line. + +```rcl +["First line", "Second line\nThird line"] +``` +Formats as: +``` +First line +Second line +Third line +``` + +> **Tip:** You can quickly select raw output on the command line with the +> [`re`](rcl_evaluate.md) and [`rq`](rcl_query.md) shorthands. + +## rcl + +Output pretty-printed RCL. + +```rcl +{ + "where_possible": "Record syntax will be used.", + "where impossible": "Expression form is used.", +} +``` +Formats as: +```rcl +{ + "where impossible": "Expression form is used.", + where_possible = "Record syntax will be used.", +} +``` + +## toml + +Alias for [`toml-1.0`](#toml-10), for maximum compatibility. + +## toml-1.0 + +Output as TOML 1.0. This version is the most widely supported, but +can be less readable because inline tables cannot span multiple lines. + +```rcl +{ + profile = { + release = { + lto = "thin", + panic = "abort", + strip = "true", + }, + }, +} +``` +Formats as: +```toml +[profile] +release = { lto = "thin", panic = "abort", strip = "true" } +``` + +## toml-1.1 + +Output TOML 1.1. This version supports multi-line tables, but it +was only released in December 2025, so it is less widely supported. The same +example as before outputs as follows: + +```toml +[profile] +release = { + lto = "thin", + panic = "abort", + strip = "true", +} +``` + +## yaml-stream + +If the document is a list, output every element as a JSON document, +prefixed by the --- YAML document separator. Top-level +values other than lists are not valid for this format. + +```rcl +[ + { name = "Roy Batty", serial = "N6MAA10816" }, + { name = "Leon Kowalski", serial = "N6MAC41717" }, +] +``` +Formats as: +```yaml +--- +{"name": "Roy Batty", "serial": "N6MAA10816"} +--- +{"name": "Leon Kowalski", "serial": "N6MAC41717"} +``` diff --git a/mkdocs.yml b/mkdocs.yml index 93012f60..89d978e3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -22,6 +22,7 @@ nav: - "Syntax highlighting": "syntax_highlighting.md" - "Generating files": "generating_files.md" - "Using Ninja": "using_ninja.md" + - "Output formats": "output_formats.md" - "Changelog": "changelog.md" - "Language guide": - "Syntax": "syntax.md" From 26cb54b1cd5376f6bf0a0a77b30e7e79039b2710 Mon Sep 17 00:00:00 2001 From: Ruud van Asseldonk Date: Sat, 11 Jul 2026 18:14:48 +0200 Subject: [PATCH 13/22] Cross-reference new output formats docs page --- docs/rcl_build.md | 3 ++- docs/rcl_evaluate.md | 47 +++++++++----------------------------------- 2 files changed, 11 insertions(+), 39 deletions(-) diff --git a/docs/rcl_build.md b/docs/rcl_build.md index 423437f4..d3fa1194 100644 --- a/docs/rcl_build.md +++ b/docs/rcl_build.md @@ -77,7 +77,8 @@ The value to write to the file in the desired format. ### format The output format (`json`, `toml`, etc.). This must be one of the formats -supported by [`--format`](rcl_evaluate.md#-f-format-format). +supported by [`--format`](rcl_evaluate.md#-f-format-format), see also the +[output formats chapter](output_formats.md). ### width diff --git a/docs/rcl_evaluate.md b/docs/rcl_evaluate.md index 08ee6203..66f857bb 100644 --- a/docs/rcl_evaluate.md +++ b/docs/rcl_evaluate.md @@ -27,46 +27,17 @@ a line break between the banner and the output. Output in the given format. The following formats are supported: -
-
json
-
Output pretty-printed JSON.
- -
json-lines
-
If the document is a list, output every element as a JSON - value on its own line, consistent with the - JSON lines format. - Top-level values other than lists are not valid for this format.
- -
raw
-
If the document is a string, output the string itself. If the document is - a list or set of strings, output each string on its own line.
- -
rcl
-
Output pretty-printed RCL.
- -
toml
-
Alias for toml-1.0.
- -
toml-1.0
-
Output TOML 1.0. - This version is the most widely supported, - but can be less readable because inline tables - cannot span multiple lines.
- -
toml-1.1
-
Output TOML 1.1. - This version supports multi-line tables, - but it was only released in December 2025, - so it is less widely supported.
- -
yaml-stream
-
If the document is a list, output every element as a JSON - document, prefixed by the --- YAML document - separator. Top-level values other than lists are not valid for this format.
-
+ * [json](output_formats.md#json) + * [json-lines](output_formats.md#json-lines) + * [raw](output_formats.md#raw) + * [rcl](output_formats.md#rcl) + * [toml](output_formats.md#toml), alias for `toml-1.0` + * [toml-1.0](output_formats.md#toml-10) + * [toml-1.1](output_formats.md#toml-11) + * [yaml-stream](output_formats.md#yaml-stream) The default output format is `rcl`. For the `je` command shorthand, the default -output format is `json`. +output format is `json`, and for `re`, the output format is `raw`. ### `--output-depfile ` From 26bcf84e3e8d4a6ffa61d89c22d300d33e042cc1 Mon Sep 17 00:00:00 2001 From: Ruud van Asseldonk Date: Sat, 11 Jul 2026 19:26:05 +0200 Subject: [PATCH 14/22] Output empty string quoted for systemd, test nesting This tests a few more weird cases that I thought of while writing the docs. Even though line coverage is at 100% already, there are some boolean expressions and recursion that were not yet covered, better to confirm it explicitly. Especially the empty string case matters, previously it was impossible to pass an empty string in a command! --- golden/systemd/lists.test | 2 ++ golden/systemd/weird_values.test | 27 +++++++++++++++++++++++++++ src/fmt_systemd.rs | 2 +- 3 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 golden/systemd/weird_values.test diff --git a/golden/systemd/lists.test b/golden/systemd/lists.test index a069e935..dedfc355 100644 --- a/golden/systemd/lists.test +++ b/golden/systemd/lists.test @@ -6,6 +6,7 @@ ["/usr/bin/hello", "--check-config"], ["/usr/bin/hello", "--init"], ["/usr/bin/hello", "--daemon", "--config", "/etc/hello/hello.cfg"], + ["/usr/bin/hello", "-P", ""], ], // Even if the key is not repeated, a nested list triggers space-separation. @@ -38,6 +39,7 @@ Environment=HOME=/home/user LANG=en_US.UTF-8 ExecStart=/usr/bin/hello --check-config ExecStart=/usr/bin/hello --init ExecStart=/usr/bin/hello --daemon --config /etc/hello/hello.cfg +ExecStart=/usr/bin/hello -P "" [WireGuardPeer] AllowedIPs=10.0.0.1/32 diff --git a/golden/systemd/weird_values.test b/golden/systemd/weird_values.test new file mode 100644 index 00000000..d6d2e422 --- /dev/null +++ b/golden/systemd/weird_values.test @@ -0,0 +1,27 @@ +{ + Test = { + // Null formats as nothing (raw empty string), + // empty string formats as a quoted empty string. + Null = null, + Empty = "", + + // A single quote turns the string quoted, even without whitespace. + Quoted = "whomst'd've", + + // A nested list becomes space-separated, and we can even nest arbitrarily. + Spaced = [ + ["{", "}"], + ["{", ["{", "}"], "}"], + ["{", ["{", ["{", "}"], "}"], "}"], + ], + } +} + +# output: +[Test] +Empty="" +Null= +Quoted="whomst'd've" +Spaced={ } +Spaced={ { } } +Spaced={ { { } } } diff --git a/src/fmt_systemd.rs b/src/fmt_systemd.rs index b29886f1..cf8e2b37 100644 --- a/src/fmt_systemd.rs +++ b/src/fmt_systemd.rs @@ -67,7 +67,7 @@ impl Formatter { use std::fmt::Write; let mut into = String::with_capacity(str.len()); - let mut needs_quotes = false; + let mut needs_quotes = str.is_empty(); for ch in str.chars() { let mut escaped = true; From 529a8203e11d23e69a7c5b7205444b021f1b5588 Mon Sep 17 00:00:00 2001 From: Ruud van Asseldonk Date: Sat, 11 Jul 2026 19:27:22 +0200 Subject: [PATCH 15/22] Document systemd output format in the manual --- docs/output_formats.md | 108 +++++++++++++++++++++++++++++++++++++++++ docs/rcl_evaluate.md | 1 + 2 files changed, 109 insertions(+) diff --git a/docs/output_formats.md b/docs/output_formats.md index 863c0df4..38d96801 100644 --- a/docs/output_formats.md +++ b/docs/output_formats.md @@ -86,6 +86,114 @@ Formats as: } ``` +## systemd + +Output as a [systemd unit][sd-syntax]. The format is superficially similar to +TOML, but handles nested data differently. The top-level value must +always be a dict. Its keys become sections. Its values must be dicts as well, +which become the section contents: + +```rcl +{ + Unit = { + Description = "Example systemd unit.", + After = "network-online.target", + }, +} +``` +```systemd +[Unit] +After=network-online.target +Description="Example systemd unit." +``` + +Systemd allows repeating keys. To emit those, use a list or set value in +RCL: + +```rcl +{ + Service = { + BindReadOnlyPaths = ["/etc/resolv.conf", "/var/www"], + }, +} +``` +```systemd +[Service] +BindReadOnlyPaths=/etc/resolv.conf +BindReadOnlyPaths=/var/www +``` + +Some settings allow space-separated values. To emit those, use a nested list +or set: + +```rcl +{ + Service = { + ExecStart = [["/usr/bin/nsd", "-P", "", "-c", "/etc/nsd.conf"]], + }, +}, +``` +```systemd +[Service] +ExecStart=/usr/bin/nsd -P "" -c /etc/nsd/nsd.conf +``` + +For some settings, systemd accepts the empty string to clear previous +assignments. While `""` works, using `null` avoids printing the quotes. +The difference between `""` and `null` is purely cosmetic. + +```rcl +{ + Service = { + Environment = [ + null, + ["LOG_LEVEL=debug", "PORT=8000"], + "ENVIRONMENT=prod", + ], + }, +} +``` +```systemd +[Service] +Environment= +Environment=LOG_LEVEL=debug PORT=8000 +Environment=ENVIRONMENT=prod +``` + +Finally, some units support repeated sections. To emit those, wrap the sections +in a list or set: + +```rcl +{ + Route = [ + { Gateway = "0.0.0.0", Table = 1 }, + { Gateway = "::", Table = 2 }, + ], +} +``` +```systemd +[Route] +Gateway=0.0.0.0 +Table=1 + +[Route] +Gateway=:: +Table=2 +``` + +RCL will use escape sequences and quote strings when needed to keep the unit +file valid, and to preserve values exactly. For some settings, such as +[`ExecStart=`][sd-start], systemd performs +[environment variable substitution][sd-var] and +[specifier substitution][sd-spec], which means that `$` and `%` have special +meaning for those settings. RCL does not apply any special handling for these +characters. + +[sd-syntax]: https://www.freedesktop.org/software/systemd/man/latest/systemd.syntax.html +[sd-start]: https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#ExecStart= +[sd-var]: https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#Command%20lines +[sd-spec]: https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html#Specifiers + ## toml Alias for [`toml-1.0`](#toml-10), for maximum compatibility. diff --git a/docs/rcl_evaluate.md b/docs/rcl_evaluate.md index 66f857bb..7eedf1c9 100644 --- a/docs/rcl_evaluate.md +++ b/docs/rcl_evaluate.md @@ -31,6 +31,7 @@ Output in the given format. The following formats are supported: * [json-lines](output_formats.md#json-lines) * [raw](output_formats.md#raw) * [rcl](output_formats.md#rcl) + * [systemd](output_formats.md#systemd) * [toml](output_formats.md#toml), alias for `toml-1.0` * [toml-1.0](output_formats.md#toml-10) * [toml-1.1](output_formats.md#toml-11) From 6d9bedf69f9765585164a5914f1bf05617252d5a Mon Sep 17 00:00:00 2001 From: Ruud van Asseldonk Date: Sat, 11 Jul 2026 19:29:51 +0200 Subject: [PATCH 16/22] Document new output format in the release notes --- docs/changelog.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index d5c4ac26..1ccb0152 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -21,6 +21,8 @@ Unreleased. * Improved error messages for unmatched brackets. * Highlighters based on Tree-sitter (e.g. those in Helix and Zed) now highlight function calls. + * Add the [`systemd` output format](output_formats.md#systemd) for generating + systemd units from RCL. ## 0.13.0 From 011cf914bf73a7c8bf7baacf48acac8566e4600b Mon Sep 17 00:00:00 2001 From: Ruud van Asseldonk Date: Sat, 11 Jul 2026 19:59:14 +0200 Subject: [PATCH 17/22] Add new CLI flag to the CLI fuzzer dictionary --- fuzz/dictionary_cli.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/fuzz/dictionary_cli.txt b/fuzz/dictionary_cli.txt index 5fc0018d..323ec17e 100644 --- a/fuzz/dictionary_cli.txt +++ b/fuzz/dictionary_cli.txt @@ -36,6 +36,7 @@ "json-lines" "none" "rcl" +"systemd" "toml" "unrestricted" "workdir" From 7e1401ec31ec7d9d9a4db65aa1068b89e7bd295b Mon Sep 17 00:00:00 2001 From: Ruud van Asseldonk Date: Fri, 17 Jul 2026 21:02:38 +0200 Subject: [PATCH 18/22] Move output formats chapter to command reference It's a chapter that has no very natural location, but for sure it is more reference than guide, and it's more related to the commands than to the language, so here we go. --- mkdocs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mkdocs.yml b/mkdocs.yml index 89d978e3..9de1e031 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -22,7 +22,6 @@ nav: - "Syntax highlighting": "syntax_highlighting.md" - "Generating files": "generating_files.md" - "Using Ninja": "using_ninja.md" - - "Output formats": "output_formats.md" - "Changelog": "changelog.md" - "Language guide": - "Syntax": "syntax.md" @@ -49,6 +48,7 @@ nav: - "rcl highlight": "rcl_highlight.md" - "rcl patch": "rcl_patch.md" - "rcl query": "rcl_query.md" + - "Output formats": "output_formats.md" - "Development": - "Overview": "development.md" - "Building": "building.md" From 2486e6f8fe3a4bf83386ce1ea176a03bef9124c2 Mon Sep 17 00:00:00 2001 From: Ruud van Asseldonk Date: Fri, 17 Jul 2026 21:14:54 +0200 Subject: [PATCH 19/22] Move output format tips above examples --- docs/output_formats.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/output_formats.md b/docs/output_formats.md index 38d96801..52d39172 100644 --- a/docs/output_formats.md +++ b/docs/output_formats.md @@ -14,6 +14,9 @@ superset of JSON, so this format is appropriate for generating configuration for tools that accept YAML, such as Kubernetes and GitHub Actions. +> **Tip:** You can quickly select JSON output on the command line +> with the [`je`](rcl_evaluate.md) and [`jq`](rcl_query.md) shorthands. + ```rcl { series = "Nexus-6", @@ -28,9 +31,6 @@ Formats as: } ``` -> **Tip:** You can quickly select JSON output on the command line -> with the [`je`](rcl_evaluate.md) and [`jq`](rcl_query.md) shorthands. - ## json-lines If the document is a list, output every element as a JSON value on @@ -55,6 +55,9 @@ Formats as: If the document is a string, output the string itself. If the document is a list or set of strings, output each string on its own line. +> **Tip:** You can quickly select raw output on the command line with the +> [`re`](rcl_evaluate.md) and [`rq`](rcl_query.md) shorthands. + ```rcl ["First line", "Second line\nThird line"] ``` @@ -65,9 +68,6 @@ Second line Third line ``` -> **Tip:** You can quickly select raw output on the command line with the -> [`re`](rcl_evaluate.md) and [`rq`](rcl_query.md) shorthands. - ## rcl Output pretty-printed RCL. From b80a078a6ae144bac632d5899449136418a2bdb5 Mon Sep 17 00:00:00 2001 From: Ruud van Asseldonk Date: Fri, 17 Jul 2026 22:01:45 +0200 Subject: [PATCH 20/22] Clarify the output formats documentation --- docs/output_formats.md | 27 ++++++++++++++++++--------- docs/rcl_evaluate.md | 3 ++- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/docs/output_formats.md b/docs/output_formats.md index 52d39172..d0af8259 100644 --- a/docs/output_formats.md +++ b/docs/output_formats.md @@ -139,8 +139,9 @@ ExecStart=/usr/bin/nsd -P "" -c /etc/nsd/nsd.conf ``` For some settings, systemd accepts the empty string to clear previous -assignments. While `""` works, using `null` avoids printing the quotes. -The difference between `""` and `null` is purely cosmetic. +assignments. While `Value=""` and `Value=` mean the same thing to systemd, +the latter is more common, and therefore might communicate the intent more +clearly. To emit an empty value from RCL, use `null`: ```rcl { @@ -196,7 +197,7 @@ characters. ## toml -Alias for [`toml-1.0`](#toml-10), for maximum compatibility. +Alias for [`toml-1.0`](#toml-10). ## toml-1.0 @@ -224,7 +225,8 @@ release = { lto = "thin", panic = "abort", strip = "true" } Output TOML 1.1. This version supports multi-line tables, but it was only released in December 2025, so it is less widely supported. The same -example as before outputs as follows: +example as before outputs as follows for a 50-column +[target line width](rcl_evaluate.md#-w-width-width): ```toml [profile] @@ -237,9 +239,10 @@ release = { ## yaml-stream -If the document is a list, output every element as a JSON document, -prefixed by the --- YAML document separator. Top-level -values other than lists are not valid for this format. +If the document is a list, output every element as a JSON document +(which is also a valid YAML document), prefixed by the --- +YAML document separator. Top-level values other than lists are not +valid for this format. ```rcl [ @@ -250,7 +253,13 @@ values other than lists are not valid for this format. Formats as: ```yaml --- -{"name": "Roy Batty", "serial": "N6MAA10816"} +{ + "name": "Roy Batty", + "serial": "N6MAA10816" +} --- -{"name": "Leon Kowalski", "serial": "N6MAC41717"} +{ + "name": "Leon Kowalski", + "serial": "N6MAC41717" +} ``` diff --git a/docs/rcl_evaluate.md b/docs/rcl_evaluate.md index 7eedf1c9..56b4df48 100644 --- a/docs/rcl_evaluate.md +++ b/docs/rcl_evaluate.md @@ -79,4 +79,5 @@ The default sandboxing mode is _workdir_. ### `-w` `--width ` -Target width for pretty-printing, in columns. Must be an integer. Defaults to 80. +Target line width for pretty-printing, in columns. Must be an integer. Defaults +to 80. Note that the formatter is not always able to stay within the desired limit. From da8074d78a8dea47b41107e4750a374276668494 Mon Sep 17 00:00:00 2001 From: Ruud van Asseldonk Date: Fri, 17 Jul 2026 22:17:04 +0200 Subject: [PATCH 21/22] Fix path attribution order in systemd output error Caught when doing a self-review of the output goldens. In case of a repeated key, and repeated section, the value we traverse has the key on the outer side and the list on the inner side, but in the output we generate the repetition is on the outside, and the key on the inside. The path in the error message should match the input value, not the output document. --- .../systemd/error_not_dict_section_list.test | 2 +- .../systemd/error_not_dict_section_set.test | 2 +- golden/systemd/error_value_method.test | 3 ++- src/fmt_systemd.rs | 24 ++++++++++++------- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/golden/systemd/error_not_dict_section_list.test b/golden/systemd/error_not_dict_section_list.test index 18b36b76..dc54172c 100644 --- a/golden/systemd/error_not_dict_section_list.test +++ b/golden/systemd/error_not_dict_section_list.test @@ -8,6 +8,6 @@ stdin:1:1 1 │ { ╵ ^ in value -at index 0 at key "Service" +at index 0 Error: Expected a dict, e.g. { WantedBy = "multi-user.target" }. diff --git a/golden/systemd/error_not_dict_section_set.test b/golden/systemd/error_not_dict_section_set.test index 249da350..dacab433 100644 --- a/golden/systemd/error_not_dict_section_set.test +++ b/golden/systemd/error_not_dict_section_set.test @@ -8,6 +8,6 @@ stdin:1:1 1 │ { ╵ ^ in value -at index 0 at key "Service" +at index 0 Error: Expected a dict, e.g. { WantedBy = "multi-user.target" }. diff --git a/golden/systemd/error_value_method.test b/golden/systemd/error_value_method.test index 3bdcbe78..a5367258 100644 --- a/golden/systemd/error_value_method.test +++ b/golden/systemd/error_value_method.test @@ -1,6 +1,6 @@ { Service = { - ExecStart = "abc".len + ExecStart = ["valid", "invalid".len] } } @@ -12,4 +12,5 @@ stdin:1:1 in value at key "Service" at key "ExecStart" +at index 1 Error: Methods cannot be exported in systemd units. diff --git a/src/fmt_systemd.rs b/src/fmt_systemd.rs index cf8e2b37..41819ea9 100644 --- a/src/fmt_systemd.rs +++ b/src/fmt_systemd.rs @@ -166,11 +166,13 @@ impl Formatter { values: impl Iterator, ) -> Result> { let mut parts: Vec> = Vec::new(); - for v in values { + for (i, v) in values.enumerate() { parts.push(self.push_key(key)?); + self.path.push(PathElement::Index(i)); parts.push("=".into()); parts.push(self.value(v)?); parts.push(Doc::HardBreak); + self.path.pop().expect("We pushed the index before."); self.path.pop().expect("We pushed the key before."); } Ok(Doc::Concat(parts)) @@ -239,12 +241,10 @@ impl Formatter { fn section<'a>( &mut self, - section_header: &'a Value, + section_header: Doc<'a>, inner: &'a Value, result: &mut Vec>, ) -> Result<()> { - let section_header = self.push_key(section_header)?; - // Separate sections by a blank line. if !result.is_empty() { result.push(Doc::HardBreak); @@ -265,8 +265,6 @@ impl Formatter { _ => self.error("Expected a dict, e.g. { WantedBy = \"multi-user.target\" }.")?, } - self.path.pop().expect("We pushed the key before."); - Ok(()) } @@ -274,27 +272,35 @@ impl Formatter { let mut result: Vec = Vec::new(); for (k, v) in kv { + // We push the header here, rather than inside `section()`, so that + // the path stack used for error reporting matches the shape of the + // value. In the case of `k = [index0, index1, ]`, we need + // to blame "key 'k', index 2", not "index 2, key 'k'"! + let section_header = self.push_key(k)?; match v { // For a list or set, we repeat the entire section. Value::List(kvs) => { for (i, kv) in kvs.iter().enumerate() { self.path.push(PathElement::Index(i)); - self.section(k, kv, &mut result)?; + self.section(section_header.clone(), kv, &mut result)?; self.path.pop(); } } Value::Set(kvs) => { for (i, kv) in kvs.iter().enumerate() { self.path.push(PathElement::Index(i)); - self.section(k, kv, &mut result)?; + self.section(section_header.clone(), kv, &mut result)?; self.path.pop(); } } // Anything else must be a dict, but we match that inside the // `section` method. - _ => self.section(k, v, &mut result)?, + _ => self.section(section_header, v, &mut result)?, } + self.path + .pop() + .expect("We pushed the section header before."); } Ok(Doc::Concat(result)) From a27fe7e9254517be017a9e81b9c069133d9d3cf1 Mon Sep 17 00:00:00 2001 From: Ruud van Asseldonk Date: Fri, 17 Jul 2026 22:31:26 +0200 Subject: [PATCH 22/22] Correct typo in comment in systemd formatter --- src/fmt_systemd.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fmt_systemd.rs b/src/fmt_systemd.rs index 41819ea9..d07deb9e 100644 --- a/src/fmt_systemd.rs +++ b/src/fmt_systemd.rs @@ -133,7 +133,7 @@ impl Formatter { Ok(ident.into()) } - /// Format a key, and push it to as path, or return an error on non-strings. + /// Format a key, and push to `self.path`, or return an error on non-strings. fn push_key<'a>(&mut self, key: &'a Value) -> Result> { self.path.push(PathElement::Key(key.clone())); match key {