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
diff --git a/docs/output_formats.md b/docs/output_formats.md
new file mode 100644
index 00000000..38d96801
--- /dev/null
+++ b/docs/output_formats.md
@@ -0,0 +1,256 @@
+# 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.",
+}
+```
+
+## 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.
+
+## 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/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..7eedf1c9 100644
--- a/docs/rcl_evaluate.md
+++ b/docs/rcl_evaluate.md
@@ -27,46 +27,18 @@ 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)
+ * [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)
+ * [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 `
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"
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..26962c8f
--- /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"
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_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_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/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_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/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/golden/systemd/lists.test b/golden/systemd/lists.test
new file mode 100644
index 00000000..dedfc355
--- /dev/null
+++ b/golden/systemd/lists.test
@@ -0,0 +1,50 @@
+{
+ 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"],
+ ["/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.
+ // 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=" },
+ ],
+
+ // 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
+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
+PublicKey=ceenqsWnNs7cH29WMopAnNPqqkRb9M5R/5DrnSY6Swk=
+
+[WireGuardPeer]
+AllowedIPs=10.0.0.2/32
+PublicKey=YiBg9Q5aUlRx5h2D24xJom7+EQzIQazin5LTuKIKtxY=
diff --git a/golden/systemd/non_string_values.test b/golden/systemd/non_string_values.test
new file mode 100644
index 00000000..237aafe6
--- /dev/null
+++ b/golden/systemd/non_string_values.test
@@ -0,0 +1,30 @@
+{
+ Unit = {
+ After = {"network-online.target", "postgresql.service"},
+ },
+ Service = {
+ RemainAfterExit = true,
+ NonBlocking = false,
+ 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
+NonBlocking=false
+RemainAfterExit=true
+RestartSec=1.5
+RestartSteps=5
+
+[Unit]
+After=network-online.target
+After=postgresql.service
diff --git a/golden/systemd/string_escapes.test b/golden/systemd/string_escapes.test
new file mode 100644
index 00000000..fe53ccc5
--- /dev/null
+++ b/golden/systemd/string_escapes.test
@@ -0,0 +1,36 @@
+{
+ Service = {
+ ExecStart = [
+ [
+ "/usr/bin/sh",
+ "-c",
+ """
+ echo 'hello' "$USER" 'Backslash: \\'
+ """
+ ],
+ [
+ "/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\" '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/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/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/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/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"
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..b5f23d7d 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 => 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..cf8e2b37
--- /dev/null
+++ b/src/fmt_systemd.rs
@@ -0,0 +1,302 @@
+// 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 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;
+
+use crate::error::{IntoError, PathElement, Result};
+use crate::markup::Markup;
+use crate::pprint::{concat, group, indent, Doc};
+use crate::runtime::Value;
+use crate::source::Span;
+
+/// 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, 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 = str.is_empty();
+
+ 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#"\\"#),
+ // 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."),
+ ch => {
+ escaped = false;
+ into.push(ch);
+ }
+ }
+
+ // 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 "\"" },
+ false => into.into(),
+ }
+ }
+
+ /// 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.");
+ }
+
+ // 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 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)),
+ _ => self.error("To export as systemd, 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.
+ ///
+ /// 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 head = None;
+ let mut tail: Vec> = Vec::new();
+ for (i, v) in values.enumerate() {
+ match i {
+ 0 => head = Some(self.value(v)?),
+ _ => {
+ tail.push(Doc::tall("\\"));
+ tail.push(Doc::Sep);
+ tail.push(self.value(v)?);
+ }
+ }
+ }
+ let result = group! {
+ head
+ indent! { Doc::Concat(tail) }
+ };
+ Ok(result)
+ }
+
+ 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),
+ 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("Dict values cannot be exported in systemd units.")
+ .map_err(|err| {
+ err.with_help(
+ "Format key-values as strings first, for example with a list comprehension.",
+ )
+ })?,
+ Value::Function(..) | 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 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 {
+ // 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();
+ }
+ }
+ 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();
+ }
+ }
+
+ // Anything else must be a dict, but we match that inside the
+ // `section` method.
+ _ => self.section(k, v, &mut result)?,
+ }
+ }
+
+ 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;