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;