Skip to main content

max / alloy

config: parse schema-DSL v1 into the form the console renders First of the two pure halves of `alloy config`. Groups expand here rather than at render time, so nothing downstream knows they existed, and a schema's own defaults and presets are checked against the fields they name: these files are hand-written and the alternative is a wrong form at runtime rather than an error at parse. deny_unknown_fields throughout, so a mistyped `rnage` fails instead of dropping a constraint without a word. schema_version is what carries forward compatibility, so leniency there bought nothing. `pattern` is held as written. Applying it needs a regex engine, which is the bind layer's dependency to take on, and no shipped schema uses one. Tested against schemas/rio.toml.schema, which is the file the format was designed against.
Author: Max Johnson <me@maxj.phd> · 2026-07-24 18:02 UTC
Signed with PGP, not checked
Commit: 1a81e4a058462826e3844aef297376aed16faf87
Parent: 471bd92
2 files changed, +501 insertions, -0 deletions
@@ -14,6 +14,7 @@
14 14 mod net;
15 15 mod pkg;
16 16 mod run;
17 + mod schema;
17 18 mod shell;
18 19 mod theme;
19 20 mod wizard;
@@ -1,0 +1,1144 @@
1 + //! Schema-DSL v1: reading a `.schema` file into the form the console renders.
2 + //!
3 + //! docs/CONSOLE.md#the-schema-format is the reference; `schemas/rio.toml.schema`
4 + //! is the worked example this module is tested against. The parser is the first
5 + //! of the two pure halves of `alloy config` (the other is the `toml_edit` bind
6 + //! layer), so nothing here touches a terminal or a target file.
7 + //!
8 + //! Two things it deliberately does more of than "deserialize the file":
9 + //!
10 + //! - **Groups are expanded here.** `[[group]]` is DSL sugar; docs/CONSOLE.md
11 + //! calls it "not a runtime concept". A group of 29 palette entries becomes 29
12 + //! ordinary [`Field`]s at `colors.<key>`, so every consumer downstream sees
13 + //! one flat list and no view has to know groups existed.
14 + //! - **Defaults and presets are checked against the fields they name.** These
15 + //! files are hand-written, dozens of them eventually, and a `default` outside
16 + //! its own `range` or a preset naming a path that no field declares is an
17 + //! authoring mistake that would otherwise surface as a wrong form at runtime.
18 + //!
19 + //! Every error out of here is a fallback diagnostic: docs/CONSOLE.md routes an
20 + //! unknown `schema_version`, an unknown field type, or an unreadable schema to
21 + //! the text-edit pane with the reason shown. So the messages are written to be
22 + //! read by whoever is editing the schema, and they name the field's path.
23 + //!
24 + //! <!-- wiki: alloy-console -->
25 +
26 + // Step 1 of the build order lands the parser on its own, so nothing in the
27 + // binary reads a `Schema` yet and every type here is dead by the compiler's
28 + // reckoning. The tests are what exercise it until the bind layer and the view
29 + // arrive; this allow comes off with them.
30 + #![allow(dead_code)]
31 +
32 + use std::path::Path;
33 +
34 + use anyhow::{Context, Result, bail, ensure};
35 + use serde::Deserialize;
36 + use toml::Value;
37 +
38 + /// The schema-DSL version this parser implements.
39 + ///
40 + /// A schema declaring anything else is not a file to guess at: v2 is already
41 + /// sketched (`enabled_when`), and a v1 editor rendering a v2 schema would drop
42 + /// the constraint silently. Fallback is the honest outcome.
43 + const DSL_VERSION: &str = "1";
44 +
45 + /// A parsed schema: the header, the UI sections in declared order, every field
46 + /// with groups already expanded, and the presets.
47 + #[derive(Debug)]
48 + pub(crate) struct Schema {
49 + pub(crate) header: Header,
50 + pub(crate) sections: Vec<Section>,
51 + pub(crate) fields: Vec<Field>,
52 + pub(crate) presets: Vec<Preset>,
53 + }
54 +
55 + /// The `[schema]` block.
56 + #[derive(Debug)]
57 + pub(crate) struct Header {
58 + pub(crate) target: String,
59 + pub(crate) target_tool: String,
60 + /// Semver range the schema was written against, e.g. `">=0.2"`. Held as
61 + /// written: matching it against the installed tool needs a version to
62 + /// compare with, which is the view's business and not the parser's.
63 + pub(crate) target_version: Option<String>,
64 + pub(crate) unknown_keys: UnknownKeys,
65 + }
66 +
67 + /// What to do about keys in the target file that no field declares.
68 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
69 + pub(crate) enum UnknownKeys {
70 + /// Keep them through the edit round-trip and report an info diagnostic.
71 + /// The default, so that a tool release adding a key cannot brick the editor.
72 + Preserve,
73 + /// Treat an undeclared key as an error.
74 + Error,
75 + }
76 +
77 + /// One collapsible pane. Fields fall into it by path prefix.
78 + #[derive(Debug)]
79 + pub(crate) struct Section {
80 + pub(crate) path: String,
81 + pub(crate) description: Option<String>,
82 + }
83 +
84 + /// One atomic edit unit: a dotted path into the target file, plus how to render
85 + /// and constrain its value.
86 + #[derive(Debug)]
87 + pub(crate) struct Field {
88 + /// Dotted TOML path. Hyphens are literal key names, not separators.
89 + pub(crate) path: String,
90 + pub(crate) description: Option<String>,
91 + pub(crate) required: bool,
92 + pub(crate) kind: FieldKind,
93 + }
94 +
95 + /// The value half of a field: its type, its constraints, and its default.
96 + ///
97 + /// One enum rather than one struct per type, for the reason recorded in
98 + /// [[alloy-console]]: the field type is runtime data read out of a schema file,
99 + /// so there is nothing for the compiler to check, and `AlloyForm` has to hold a
100 + /// heterogeneous row list regardless.
101 + #[derive(Debug)]
102 + pub(crate) enum FieldKind {
103 + Bool {
104 + default: Option<bool>,
105 + },
106 + Int {
107 + default: Option<i64>,
108 + range: Option<(i64, i64)>,
109 + },
110 + Float {
111 + default: Option<f64>,
112 + range: Option<(f64, f64)>,
113 + },
114 + Str {
115 + default: Option<String>,
116 + /// Held as written. Applying it needs a regex engine, which is a
117 + /// dependency the bind layer gets to decide on; no shipped schema uses
118 + /// one yet.
119 + pattern: Option<String>,
120 + },
121 + Color {
122 + default: Option<String>,
123 + format: ColorFormat,
124 + },
125 + Path {
126 + default: Option<String>,
127 + format: PathFormat,
128 + must_exist: bool,
129 + },
130 + Enum {
131 + default: Option<String>,
132 + values: Vec<EnumValue>,
133 + },
134 + /// A repeating record, e.g. `[[bindings.keys]]`. Read-only in v1.
135 + List {
136 + element: Vec<Field>,
137 + },
138 + }
139 +
140 + impl FieldKind {
141 + /// The `type = ` spelling this kind came from, for diagnostics.
142 + pub(crate) fn type_name(&self) -> &'static str {
143 + match self {
144 + Self::Bool { .. } => "bool",
145 + Self::Int { .. } => "int",
146 + Self::Float { .. } => "float",
147 + Self::Str { .. } => "string",
148 + Self::Color { .. } => "color",
149 + Self::Path { .. } => "path",
150 + Self::Enum { .. } => "enum",
151 + Self::List { .. } => "list",
152 + }
153 + }
154 + }
155 +
156 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
157 + pub(crate) enum ColorFormat {
158 + /// `#rrggbb`
159 + Hex,
160 + /// `#rrggbbaa`
161 + HexAlpha,
162 + /// Any CSS color notation; not shape-checked here.
163 + Css,
164 + }
165 +
166 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
167 + pub(crate) enum PathFormat {
168 + File,
169 + Dir,
170 + Any,
171 + }
172 +
173 + /// One choice in an `enum` field.
174 + ///
175 + /// Flat schema entries (`values = ["High", "Low"]`) label themselves; the
176 + /// structured form exists for raw values that read badly on their own, which is
177 + /// why rio's `"Disabled"` gets "No decorations (recommended under Sway)".
178 + #[derive(Debug)]
179 + pub(crate) struct EnumValue {
180 + pub(crate) value: String,
181 + pub(crate) label: String,
182 + pub(crate) description: Option<String>,
183 + }
184 +
185 + /// A bundle of values applied in one action: one dirty increment, one undo entry.
186 + #[derive(Debug)]
187 + pub(crate) struct Preset {
188 + pub(crate) name: String,
189 + pub(crate) description: Option<String>,
190 + /// Field path to value, sorted by path. Every path here names a declared
191 + /// field; that is checked at parse time.
192 + pub(crate) values: Vec<(String, Value)>,
193 + }
194 +
195 + impl Schema {
196 + /// Read and parse a schema file.
197 + pub(crate) fn load(path: &Path) -> Result<Self> {
198 + let text = std::fs::read_to_string(path)
199 + .with_context(|| format!("reading schema {}", path.display()))?;
200 + Self::parse(&text).with_context(|| format!("in schema {}", path.display()))
201 + }
202 +
203 + pub(crate) fn parse(text: &str) -> Result<Self> {
204 + let raw: RawSchema = toml::from_str(text)?;
205 +
206 + ensure!(
207 + raw.schema.schema_version == DSL_VERSION,
208 + "schema_version is \"{}\", and this console implements schema-DSL v{DSL_VERSION}",
209 + raw.schema.schema_version,
210 + );
211 +
212 + let unknown_keys = match raw.schema.unknown_keys.as_deref() {
213 + None | Some("preserve") => UnknownKeys::Preserve,
214 + Some("error") => UnknownKeys::Error,
215 + Some(other) => bail!("unknown_keys is \"{other}\", expected \"preserve\" or \"error\""),
216 + };
217 +
218 + let header = Header {
219 + target: raw.schema.target,
220 + target_tool: raw.schema.target_tool,
221 + target_version: raw.schema.target_version,
222 + unknown_keys,
223 + };
224 +
225 + let sections = raw
226 + .section
227 + .into_iter()
228 + .map(|section| Section {
229 + path: section.path,
230 + description: section.description,
231 + })
232 + .collect();
233 +
234 + // Declaration order across both blocks: fields first, then the groups
235 + // they are sugar for. The schema file already reads that way (rio
236 + // declares `[[group]] colors` after every `[[field]]`), and a form whose
237 + // row order depends on how the author interleaved two block types would
238 + // be a surprise waiting to happen.
239 + let mut fields = Vec::new();
240 + for field in raw.field {
241 + let path = field.path.clone();
242 + fields.push(
243 + field
244 + .normalize()
245 + .with_context(|| format!("field `{path}`"))?,
246 + );
247 + }
248 + for group in raw.group {
249 + fields.extend(
250 + group
251 + .expand()
252 + .map(|expanded| expanded.with_context(|| "group"))
253 + .collect::<Result<Vec<_>>>()?,
254 + );
255 + }
256 +
257 + // A duplicate path is two rows editing one key, where the second edit
258 + // silently wins. Cheap to catch, impossible to see in a rendered form.
259 + for (index, field) in fields.iter().enumerate() {
260 + if let Some(earlier) = fields[..index]
261 + .iter()
262 + .find(|other| other.path == field.path)
263 + {
264 + bail!("`{}` is declared twice", earlier.path);
265 + }
266 + }
267 +
268 + let presets = raw
269 + .preset
270 + .into_iter()
271 + .map(|preset| preset.normalize(&fields))
272 + .collect::<Result<Vec<_>>>()?;
273 +
274 + Ok(Self {
275 + header,
276 + sections,
277 + fields,
278 + presets,
279 + })
280 + }
281 +
282 + /// The field at a dotted path, if the schema declares one.
283 + pub(crate) fn field(&self, path: &str) -> Option<&Field> {
284 + self.fields.iter().find(|field| field.path == path)
285 + }
286 +
287 + /// The section a field's path falls into.
288 + ///
289 + /// Longest match wins, so a schema declaring both `colors` and
290 + /// `colors.bright` puts `colors.bright.red` under the more specific one.
291 + /// Matching is on whole path segments: a section `font` does not swallow
292 + /// `fonts.size`.
293 + pub(crate) fn section_of(&self, path: &str) -> Option<&Section> {
294 + self.sections
295 + .iter()
296 + .filter(|section| {
297 + path == section.path
298 + || path
299 + .strip_prefix(&section.path)
300 + .is_some_and(|rest| rest.starts_with('.'))
301 + })
302 + .max_by_key(|section| section.path.len())
303 + }
304 + }
305 +
306 + // ---------------------------------------------------------------------------
307 + // The file as written. `deny_unknown_fields` throughout: a mistyped `rnage`
308 + // would otherwise drop a constraint without a word, and `schema_version` is
309 + // what carries forward compatibility, so leniency here buys nothing.
310 + // ---------------------------------------------------------------------------
311 +
312 + #[derive(Deserialize)]
313 + #[serde(deny_unknown_fields)]
314 + struct RawSchema {
315 + schema: RawHeader,
316 + #[serde(default)]
317 + section: Vec<RawSection>,
318 + #[serde(default)]
319 + field: Vec<RawField>,
320 + #[serde(default)]
321 + group: Vec<RawGroup>,
322 + #[serde(default)]
323 + preset: Vec<RawPreset>,
324 + }
325 +
326 + #[derive(Deserialize)]
327 + #[serde(deny_unknown_fields)]
328 + struct RawHeader {
329 + target: String,
330 + target_tool: String,
331 + target_version: Option<String>,
332 + schema_version: String,
333 + unknown_keys: Option<String>,
334 + }
335 +
336 + #[derive(Deserialize)]
337 + #[serde(deny_unknown_fields)]
338 + struct RawSection {
339 + path: String,
340 + description: Option<String>,
341 + }
342 +
343 + #[derive(Deserialize)]
344 + #[serde(deny_unknown_fields)]
345 + struct RawField {
346 + path: String,
347 + #[serde(rename = "type")]
348 + ty: String,
349 + description: Option<String>,
350 + default: Option<Value>,
351 + range: Option<Vec<Value>>,
352 + pattern: Option<String>,
353 + values: Option<Vec<Value>>,
354 + format: Option<String>,
355 + must_exist: Option<bool>,
356 + required: Option<bool>,
357 + element: Option<Value>,
358 + }
359 +
360 + #[derive(Deserialize)]
361 + #[serde(deny_unknown_fields)]
362 + struct RawElement {
363 + #[serde(rename = "type")]
364 + ty: String,
365 + fields: Vec<RawField>,
366 + }
367 +
368 + #[derive(Deserialize)]
369 + #[serde(deny_unknown_fields)]
370 + struct RawGroup {
371 + path: String,
372 + #[serde(rename = "type")]
373 + ty: String,
374 + description: Option<String>,
375 + format: Option<String>,
376 + entries: Vec<RawEntry>,
377 + }
378 +
379 + #[derive(Deserialize)]
380 + #[serde(deny_unknown_fields)]
381 + struct RawEntry {
382 + key: String,
383 + default: Option<Value>,
384 + description: Option<String>,
385 + }
386 +
387 + #[derive(Deserialize)]
388 + #[serde(deny_unknown_fields)]
389 + struct RawPreset {
390 + name: String,
391 + description: Option<String>,
392 + values: toml::Table,
393 + }
394 +
395 + impl RawField {
396 + fn normalize(self) -> Result<Field> {
397 + let kind = self.kind()?;
398 + Ok(Field {
399 + path: self.path,
400 + description: self.description,
401 + required: self.required.unwrap_or(false),
402 + kind,
403 + })
404 + }
405 +
406 + fn kind(&self) -> Result<FieldKind> {
407 + // Every arm rejects the constraints that do not belong to it, so a
408 + // `range` on a string is a parse error rather than a line the author
409 + // believes is doing something.
410 + let kind = match self.ty.as_str() {
411 + "bool" => {
412 + self.reject(&["range", "pattern", "values", "format", "element"])?;
413 + FieldKind::Bool {
414 + default: self.default.as_ref().map(as_bool).transpose()?,
415 + }
416 + }
417 + "int" => {
418 + self.reject(&["pattern", "values", "format", "element"])?;
419 + let range = self.int_range()?;
420 + let default = self.default.as_ref().map(as_int).transpose()?;
421 + if let (Some(value), Some((low, high))) = (default, range) {
422 + ensure!(
423 + (low..=high).contains(&value),
424 + "default {value} is outside range [{low}, {high}]",
425 + );
426 + }
427 + FieldKind::Int { default, range }
428 + }
429 + "float" => {
430 + self.reject(&["pattern", "values", "format", "element"])?;
431 + let range = self.float_range()?;
432 + let default = self.default.as_ref().map(as_float).transpose()?;
433 + if let (Some(value), Some((low, high))) = (default, range) {
434 + ensure!(
435 + (low..=high).contains(&value),
436 + "default {value} is outside range [{low}, {high}]",
437 + );
438 + }
439 + FieldKind::Float { default, range }
440 + }
441 + "string" => {
442 + self.reject(&["range", "values", "format", "element"])?;
443 + FieldKind::Str {
444 + default: self.default.as_ref().map(as_string).transpose()?,
445 + pattern: self.pattern.clone(),
446 + }
447 + }
448 + "color" => {
449 + self.reject(&["range", "pattern", "values", "element"])?;
450 + let format = color_format(self.format.as_deref())?;
451 + let default = self.default.as_ref().map(as_string).transpose()?;
452 + if let Some(value) = &default {
453 + check_color(value, format)?;
454 + }
455 + FieldKind::Color { default, format }
456 + }
457 + "path" => {
458 + self.reject(&["range", "pattern", "values", "element"])?;
459 + FieldKind::Path {
460 + default: self.default.as_ref().map(as_string).transpose()?,
461 + format: path_format(self.format.as_deref())?,
462 + must_exist: self.must_exist.unwrap_or(false),
463 + }
464 + }
465 + "enum" => {
466 + self.reject(&["range", "pattern", "format", "element"])?;
467 + let values = self.enum_values()?;
468 + let default = self.default.as_ref().map(as_string).transpose()?;
469 + if let Some(value) = &default {
470 + ensure!(
471 + values.iter().any(|choice| &choice.value == value),
472 + "default \"{value}\" is not one of the declared values",
473 + );
474 + }
475 + FieldKind::Enum { default, values }
476 + }
477 + "list" => {
478 + self.reject(&["range", "pattern", "values", "format"])?;
479 + FieldKind::List {
480 + element: self.list_element()?,
481 + }
482 + }
483 + other => bail!(
484 + "type \"{other}\" is not a schema-DSL v{DSL_VERSION} type \
485 + (bool, int, float, string, color, path, enum, list)",
486 + ),
487 + };
488 + Ok(kind)
489 + }
490 +
491 + /// Error out on any constraint that does not apply to this field's type.
492 + fn reject(&self, names: &[&str]) -> Result<()> {
493 + for name in names {
494 + let present = match *name {
495 + "range" => self.range.is_some(),
496 + "pattern" => self.pattern.is_some(),
497 + "values" => self.values.is_some(),
498 + "format" => self.format.is_some(),
499 + "element" => self.element.is_some(),
500 + _ => false,
Lines truncated