cosmic-conf: parser, schema registry, resolve with projection folding

Spike results corrected the spec: ThemeBuilder.gaps is (outer, inner) at
cosmic-theme/src/model/theme.rs:895, lives under CosmicTheme.{Dark,Light}.Builder
rather than CosmicTk, and fans out to two components. Entry therefore carries
targets: &[Target].

34 tests, including the folding property that keeps gaps_out from clobbering
gaps_in. Bare #rrggbb colours rejected: # begins a comment, as in Hyprland.
This commit is contained in:
2026-08-09 22:15:57 +07:00
parent 39008709e3
commit 09f90f65de
8 changed files with 1323 additions and 3 deletions
+2 -1
View File
@@ -2,5 +2,6 @@
.omc/ .omc/
# Rust # Rust
/target/ target/
**/*.rs.bk **/*.rs.bk
vendor/
+7
View File
@@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "cosmic-conf"
version = "0.1.0"
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "cosmic-conf"
version = "0.1.0"
edition = "2021"
license = "GPL-3.0-only"
description = "Compiles a single Hyprland-idiom config file into the cosmic-config tree"
[dependencies]
[features]
# `emit` links cosmic-config and the component crates. Off by default so the
# pure units (parser, schema, resolve) build and test without the libcosmic
# dependency graph.
default = []
emit = []
+83
View File
@@ -0,0 +1,83 @@
//! Compiles a single Hyprland-idiom config file into the cosmic-config tree.
//!
//! The pipeline is `parser -> schema -> resolve -> emit`, and it is transactional:
//! `resolve` validates everything before `emit` writes anything, so a malformed
//! file leaves the desktop untouched rather than half-applied.
//!
//! `parser`, `schema` and `resolve` are pure and depend on nothing
//! COSMIC-specific, which keeps the hard logic testable without a compositor
//! running. Only `emit` binds to cosmic-config, behind the `emit` feature.
pub mod parser;
pub mod resolve;
pub mod schema;
pub use parser::{parse, Ast, ParseError, Span};
pub use resolve::{resolve, Diagnostic, Resolved, Value, Write, WriteKind};
/// Render a diagnostic against source text, cargo-style.
pub fn render_diagnostic(source: &str, span: Span, message: &str, help: Option<&str>) -> String {
let line = source.lines().nth(span.line.saturating_sub(1)).unwrap_or("");
let gutter = span.line.to_string().len();
let pad = " ".repeat(gutter);
let caret = " ".repeat(span.col.saturating_sub(1)) + &"^".repeat(span.len.max(1));
let mut out = format!(
"error: {message}\n\
{pad}--> cosmic.conf:{}:{}\n\
{pad} |\n\
{} | {line}\n\
{pad} | {caret}",
span.line, span.col, span.line
);
if let Some(help) = help {
out.push_str(&format!(" {help}"));
}
out.push('\n');
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn end_to_end_valid_config_produces_writes() {
let src = "\
$accent = rgb(6b9fed)
$gap = 4
general {
gaps_in = $gap
gaps_out = $gap * 2
autotile = true
}
theme {
accent = $accent
}
";
let ast = parse(src).expect("parse");
let r = resolve(&ast).expect("resolve");
assert!(!r.writes.is_empty());
// gaps fold per builder; accent fans out to both; autotile is direct.
let gaps: Vec<_> = r.writes.iter().filter(|w| w.target.key == "gaps").collect();
assert_eq!(gaps.len(), 2);
let accent: Vec<_> = r.writes.iter().filter(|w| w.target.key == "accent").collect();
assert_eq!(accent.len(), 2);
}
#[test]
fn diagnostic_rendering_points_at_the_offending_token() {
let src = "general {\n gaps_inn = 8\n}\n";
let ast = parse(src).unwrap();
let diags = resolve(&ast).unwrap_err();
let out = render_diagnostic(src, diags[0].span, &diags[0].message, diags[0].help.as_deref());
assert!(out.contains("unknown key"), "{out}");
assert!(out.contains("cosmic.conf:2:5"), "{out}");
assert!(out.contains("^^^^^^^^"), "{out}");
assert!(out.contains("did you mean"), "{out}");
}
}
+338
View File
@@ -0,0 +1,338 @@
//! `cosmic.conf` text -> AST.
//!
//! Hyprland-idiom, line-based grammar:
//!
//! ```text
//! # comment
//! $var = value
//! section {
//! key = value
//! nested { key = value }
//! }
//! bind = SUPER, Q, close # repeatable keys are kept in order
//! source = ~/other.conf
//! ```
//!
//! Values are kept as raw strings here; typing happens in `resolve`, which needs
//! the schema to know what a value should be.
use std::fmt;
/// Byte-independent source position. Line and column are 1-based so they match
/// what an editor shows.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Span {
pub line: usize,
pub col: usize,
pub len: usize,
}
impl Span {
pub fn new(line: usize, col: usize, len: usize) -> Self {
Self { line, col, len }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Spanned<T> {
pub value: T,
pub span: Span,
}
impl<T> Spanned<T> {
pub fn new(value: T, span: Span) -> Self {
Self { value, span }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Item {
/// `$name = value`
VarDef {
name: Spanned<String>,
value: Spanned<String>,
},
/// `key = value` inside the current section
Assign {
key: Spanned<String>,
value: Spanned<String>,
},
/// `name { .. }`
Section {
name: Spanned<String>,
items: Vec<Item>,
},
/// `source = path`
Source { path: Spanned<String> },
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Ast {
pub items: Vec<Item>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseError {
pub message: String,
pub span: Span,
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}: {}", self.span.line, self.span.col, self.message)
}
}
impl std::error::Error for ParseError {}
/// Strip a trailing `#` comment, respecting nothing else — the grammar has no
/// string literals, so there is no quoting to honour.
fn strip_comment(line: &str) -> &str {
match line.find('#') {
Some(i) => &line[..i],
None => line,
}
}
/// Column (1-based) of the first non-whitespace byte.
fn indent_col(line: &str) -> usize {
line.len() - line.trim_start().len() + 1
}
pub fn parse(input: &str) -> Result<Ast, ParseError> {
let mut cursor = Cursor {
lines: input.lines().collect(),
idx: 0,
};
let items = parse_items(&mut cursor, 0)?;
Ok(Ast { items })
}
struct Cursor<'a> {
lines: Vec<&'a str>,
idx: usize,
}
/// Parse items until EOF (`depth == 0`) or a closing brace.
fn parse_items(cur: &mut Cursor, depth: usize) -> Result<Vec<Item>, ParseError> {
let mut items = Vec::new();
while cur.idx < cur.lines.len() {
let raw = cur.lines[cur.idx];
let line_no = cur.idx + 1;
let content = strip_comment(raw).trim_end();
let trimmed = content.trim();
if trimmed.is_empty() {
cur.idx += 1;
continue;
}
if trimmed == "}" {
if depth == 0 {
return Err(ParseError {
message: "unmatched `}`".into(),
span: Span::new(line_no, indent_col(content), 1),
});
}
cur.idx += 1;
return Ok(items);
}
// `name {` opens a section. A one-line `name { .. }` is not supported;
// keeping the grammar strictly line-based keeps spans honest.
if let Some(name) = trimmed.strip_suffix('{') {
let name = name.trim();
if name.is_empty() {
return Err(ParseError {
message: "section is missing a name".into(),
span: Span::new(line_no, indent_col(content), 1),
});
}
let span = Span::new(line_no, indent_col(content), name.len());
cur.idx += 1;
let inner = parse_items(cur, depth + 1)?;
items.push(Item::Section {
name: Spanned::new(name.to_string(), span),
items: inner,
});
continue;
}
let Some(eq) = content.find('=') else {
return Err(ParseError {
message: format!("expected `key = value`, found `{trimmed}`"),
span: Span::new(line_no, indent_col(content), trimmed.len()),
});
};
let key_raw = &content[..eq];
let val_raw = &content[eq + 1..];
let key = key_raw.trim();
let value = val_raw.trim();
if key.is_empty() {
return Err(ParseError {
message: "assignment is missing a key".into(),
span: Span::new(line_no, 1, eq.max(1)),
});
}
let key_col = indent_col(content);
let key_span = Span::new(line_no, key_col, key.len());
// Column of the value = everything before it, plus its own leading trim.
let val_col = eq + 2 + (val_raw.len() - val_raw.trim_start().len());
let val_span = Span::new(line_no, val_col, value.len());
let item = if let Some(var) = key.strip_prefix('$') {
if var.is_empty() {
return Err(ParseError {
message: "variable is missing a name after `$`".into(),
span: key_span,
});
}
Item::VarDef {
name: Spanned::new(var.to_string(), key_span),
value: Spanned::new(value.to_string(), val_span),
}
} else if key == "source" {
Item::Source {
path: Spanned::new(value.to_string(), val_span),
}
} else {
Item::Assign {
key: Spanned::new(key.to_string(), key_span),
value: Spanned::new(value.to_string(), val_span),
}
};
items.push(item);
cur.idx += 1;
}
if depth != 0 {
let last = cur.lines.len().max(1);
return Err(ParseError {
message: "unclosed section: expected `}`".into(),
span: Span::new(last, 1, 1),
});
}
Ok(items)
}
#[cfg(test)]
mod tests {
use super::*;
fn assign(items: &[Item], key: &str) -> String {
items
.iter()
.find_map(|i| match i {
Item::Assign { key: k, value } if k.value == key => Some(value.value.clone()),
_ => None,
})
.unwrap_or_else(|| panic!("no assignment named `{key}`"))
}
fn section<'a>(items: &'a [Item], name: &str) -> &'a [Item] {
items
.iter()
.find_map(|i| match i {
Item::Section { name: n, items } if n.value == name => Some(items.as_slice()),
_ => None,
})
.unwrap_or_else(|| panic!("no section named `{name}`"))
}
#[test]
fn parses_flat_assignments() {
let ast = parse("autotile = true\nrounding = 10\n").unwrap();
assert_eq!(assign(&ast.items, "autotile"), "true");
assert_eq!(assign(&ast.items, "rounding"), "10");
}
#[test]
fn parses_variables() {
let ast = parse("$accent = rgb(6b9fed)\n").unwrap();
match &ast.items[0] {
Item::VarDef { name, value } => {
assert_eq!(name.value, "accent");
assert_eq!(value.value, "rgb(6b9fed)");
}
other => panic!("expected VarDef, got {other:?}"),
}
}
#[test]
fn parses_nested_sections() {
let src = "decoration {\n rounding = 10\n blur {\n size = 6\n }\n}\n";
let ast = parse(src).unwrap();
let deco = section(&ast.items, "decoration");
assert_eq!(assign(deco, "rounding"), "10");
assert_eq!(assign(section(deco, "blur"), "size"), "6");
}
#[test]
fn strips_comments_but_keeps_values() {
let ast = parse("gaps_in = 3 # inner gap\n# whole line\n").unwrap();
assert_eq!(assign(&ast.items, "gaps_in"), "3");
assert_eq!(ast.items.len(), 1);
}
#[test]
fn source_is_its_own_item() {
let ast = parse("source = ~/.config/hyprcosmic/monitors.conf\n").unwrap();
match &ast.items[0] {
Item::Source { path } => assert_eq!(path.value, "~/.config/hyprcosmic/monitors.conf"),
other => panic!("expected Source, got {other:?}"),
}
}
#[test]
fn repeatable_keys_are_preserved_in_order() {
let ast = parse("bind = SUPER, Return, spawn, kitty\nbind = SUPER, Q, close\n").unwrap();
let binds: Vec<_> = ast
.items
.iter()
.filter_map(|i| match i {
Item::Assign { key, value } if key.value == "bind" => Some(value.value.as_str()),
_ => None,
})
.collect();
assert_eq!(binds, vec!["SUPER, Return, spawn, kitty", "SUPER, Q, close"]);
}
#[test]
fn spans_point_at_the_key() {
let ast = parse("general {\n gaps_inn = 8\n}\n").unwrap();
let inner = section(&ast.items, "general");
match &inner[0] {
Item::Assign { key, .. } => {
assert_eq!(key.span.line, 2);
assert_eq!(key.span.col, 5);
assert_eq!(key.span.len, "gaps_inn".len());
}
other => panic!("expected Assign, got {other:?}"),
}
}
#[test]
fn rejects_unclosed_section() {
let err = parse("general {\n gaps_in = 3\n").unwrap_err();
assert!(err.message.contains("unclosed section"), "{}", err.message);
}
#[test]
fn rejects_unmatched_brace() {
let err = parse("}\n").unwrap_err();
assert!(err.message.contains("unmatched"), "{}", err.message);
}
#[test]
fn rejects_line_without_equals() {
let err = parse("this is not valid\n").unwrap_err();
assert!(err.message.contains("expected `key = value`"), "{}", err.message);
assert_eq!(err.span.line, 1);
}
}
+493
View File
@@ -0,0 +1,493 @@
//! AST + schema -> validated, folded writes.
//!
//! Two jobs matter here:
//!
//! 1. **Validation is total before anything is emitted.** A malformed file must
//! leave the desktop untouched rather than half-applied, so `resolve` returns
//! every diagnostic it can find and `emit` never sees a partial result.
//! 2. **Projections are folded per target.** Several conf keys can write into
//! one composite cosmic-config value (`gaps_in` and `gaps_out` are two halves
//! of one `(u32, u32)`). Writing them independently would let the second
//! clobber the first, so they are merged into a single write.
use std::collections::BTreeMap;
use crate::parser::{Ast, Item, Span, Spanned};
use crate::schema::{self, Entry, Range, Target, Ty};
#[derive(Debug, Clone, PartialEq)]
pub enum Value {
Bool(bool),
U32(u32),
F32(f32),
Str(String),
/// Straight RGBA bytes; conversion to COSMIC's f32 colour struct happens in `emit`.
Color(u8, u8, u8, u8),
}
#[derive(Debug, Clone, PartialEq)]
pub struct Diagnostic {
pub message: String,
pub span: Span,
pub help: Option<String>,
}
/// Identifies one cosmic-config value. Ordering is deterministic so emitted
/// writes are stable across runs, which keeps `--diff` output readable.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct TargetKey {
pub component: String,
pub version: u8,
pub key: String,
}
#[derive(Debug, Clone, PartialEq)]
pub enum WriteKind {
/// The conf key owns the whole value.
Whole(Value),
/// Field path -> value, folded from every conf key touching this target.
Projected(BTreeMap<Vec<String>, Value>),
}
#[derive(Debug, Clone, PartialEq)]
pub struct Write {
pub target: TargetKey,
pub kind: WriteKind,
}
#[derive(Debug, Default)]
pub struct Resolved {
pub writes: Vec<Write>,
}
/// Flatten the AST into dotted `section.key` paths, dropping `source` items —
/// include expansion happens before `resolve` so that spans stay attributable
/// to the file they came from.
fn flatten(items: &[Item], prefix: &str, out: &mut Vec<(String, Spanned<String>, Span)>) {
for item in items {
match item {
Item::Section { name, items } => {
let next = if prefix.is_empty() {
name.value.clone()
} else {
format!("{prefix}.{}", name.value)
};
flatten(items, &next, out);
}
Item::Assign { key, value } => {
let dotted = if prefix.is_empty() {
key.value.clone()
} else {
format!("{prefix}.{}", key.value)
};
out.push((dotted, value.clone(), key.span));
}
Item::VarDef { .. } | Item::Source { .. } => {}
}
}
}
fn collect_vars(items: &[Item], out: &mut BTreeMap<String, String>) {
for item in items {
match item {
Item::VarDef { name, value } => {
out.insert(name.value.clone(), value.value.clone());
}
Item::Section { items, .. } => collect_vars(items, out),
_ => {}
}
}
}
/// Substitute `$name` occurrences. Longest-name-first avoids `$gap` eating the
/// prefix of `$gaps`.
fn expand_vars(input: &str, vars: &BTreeMap<String, String>) -> String {
if !input.contains('$') {
return input.to_string();
}
let mut names: Vec<&String> = vars.keys().collect();
names.sort_by_key(|n| std::cmp::Reverse(n.len()));
let mut out = input.to_string();
for name in names {
out = out.replace(&format!("${name}"), &vars[name]);
}
out
}
/// Evaluate the tiny arithmetic the format allows: `a * b`, `a + b`, `a - b`.
/// Anything else is returned untouched for the type parser to reject.
fn eval_arith(input: &str) -> String {
for op in ['*', '+', '-'] {
if let Some((l, r)) = input.split_once(op) {
let (l, r) = (l.trim(), r.trim());
if let (Ok(a), Ok(b)) = (l.parse::<f64>(), r.parse::<f64>()) {
let v = match op {
'*' => a * b,
'+' => a + b,
_ => a - b,
};
return if v.fract() == 0.0 {
format!("{}", v as i64)
} else {
format!("{v}")
};
}
}
}
input.to_string()
}
/// Parse `rgb(rrggbb)` or `rgba(rrggbbaa)`.
///
/// Bare `#rrggbb` is deliberately **not** accepted: `#` begins a comment, so the
/// value would be stripped before reaching here. Hyprland makes the same
/// trade-off, and HyDE themes write colours as `rgba(...)`, so nothing is lost.
fn parse_color(raw: &str) -> Option<Value> {
let s = raw.trim();
let hex = if let Some(inner) = s.strip_prefix("rgba(").and_then(|s| s.strip_suffix(')')) {
inner.trim().to_string()
} else if let Some(inner) = s.strip_prefix("rgb(").and_then(|s| s.strip_suffix(')')) {
inner.trim().to_string()
} else {
return None;
};
let hex = hex.trim_start_matches('#');
match hex.len() {
6 => Some(Value::Color(
u8::from_str_radix(&hex[0..2], 16).ok()?,
u8::from_str_radix(&hex[2..4], 16).ok()?,
u8::from_str_radix(&hex[4..6], 16).ok()?,
255,
)),
8 => Some(Value::Color(
u8::from_str_radix(&hex[0..2], 16).ok()?,
u8::from_str_radix(&hex[2..4], 16).ok()?,
u8::from_str_radix(&hex[4..6], 16).ok()?,
u8::from_str_radix(&hex[6..8], 16).ok()?,
)),
_ => None,
}
}
fn coerce(raw: &str, ty: Ty, span: Span) -> Result<Value, Diagnostic> {
let bad = |expected: &str| Diagnostic {
message: format!("expected {expected}, found `{raw}`"),
span,
help: None,
};
match ty {
Ty::Bool => match raw {
"true" | "yes" | "on" | "1" => Ok(Value::Bool(true)),
"false" | "no" | "off" | "0" => Ok(Value::Bool(false)),
_ => Err(bad("a boolean (true/false/yes/no/on/off)")),
},
Ty::U32 => raw
.parse::<u32>()
.map(Value::U32)
.map_err(|_| bad("a non-negative integer")),
Ty::F32 => raw
.parse::<f32>()
.map(Value::F32)
.map_err(|_| bad("a number")),
Ty::Str => Ok(Value::Str(raw.to_string())),
Ty::Color => parse_color(raw).ok_or_else(|| bad("a colour like rgb(6b9fed) or rgba(6b9fed80)")),
}
}
fn check_range(v: &Value, range: Option<Range>, span: Span) -> Result<(), Diagnostic> {
let Some(r) = range else { return Ok(()) };
let n = match v {
Value::U32(n) => *n as f64,
Value::F32(n) => *n as f64,
_ => return Ok(()),
};
if n < r.min || n > r.max {
return Err(Diagnostic {
message: format!("value {n} is outside the allowed range {}..={}", r.min, r.max),
span,
help: None,
});
}
Ok(())
}
/// Resolve an AST against the registry.
///
/// Returns **all** diagnostics rather than the first, so a user fixing a config
/// sees the whole picture in one pass.
pub fn resolve(ast: &Ast) -> Result<Resolved, Vec<Diagnostic>> {
let mut vars = BTreeMap::new();
collect_vars(&ast.items, &mut vars);
let mut flat = Vec::new();
flatten(&ast.items, "", &mut flat);
let mut diags = Vec::new();
// (target) -> folded projections, plus whole-value writes kept separate so
// a collision between the two can be reported rather than silently resolved.
let mut projected: BTreeMap<TargetKey, BTreeMap<Vec<String>, Value>> = BTreeMap::new();
let mut whole: BTreeMap<TargetKey, (Value, Span)> = BTreeMap::new();
for (conf, raw_value, key_span) in &flat {
let Some(entry) = schema::lookup(conf) else {
diags.push(Diagnostic {
message: format!("unknown key `{conf}`"),
span: *key_span,
help: schema::suggest(conf).map(|s| format!("did you mean `{s}`?")),
});
continue;
};
let expanded = eval_arith(&expand_vars(&raw_value.value, &vars));
let value = match coerce(&expanded, entry.ty, raw_value.span) {
Ok(v) => v,
Err(d) => {
diags.push(d);
continue;
}
};
if let Err(d) = check_range(&value, entry.validate, raw_value.span) {
diags.push(d);
continue;
}
record(entry, value, raw_value.span, &mut projected, &mut whole, &mut diags);
}
if !diags.is_empty() {
return Err(diags);
}
let mut writes: Vec<Write> = whole
.into_iter()
.map(|(target, (v, _))| Write {
target,
kind: WriteKind::Whole(v),
})
.collect();
writes.extend(projected.into_iter().map(|(target, fields)| Write {
target,
kind: WriteKind::Projected(fields),
}));
writes.sort_by(|a, b| a.target.cmp(&b.target));
Ok(Resolved { writes })
}
fn record(
entry: &Entry,
value: Value,
span: Span,
projected: &mut BTreeMap<TargetKey, BTreeMap<Vec<String>, Value>>,
whole: &mut BTreeMap<TargetKey, (Value, Span)>,
diags: &mut Vec<Diagnostic>,
) {
for target in entry.targets {
let tk = TargetKey {
component: target.component().to_string(),
version: target.version(),
key: target.key().to_string(),
};
match target {
Target::Direct { .. } => {
if projected.contains_key(&tk) {
diags.push(Diagnostic {
message: format!(
"`{}` writes all of `{}`, but another key writes one of its fields",
entry.conf, tk.key
),
span,
help: None,
});
continue;
}
whole.insert(tk, (value.clone(), span));
}
Target::Projected { path, .. } => {
if whole.contains_key(&tk) {
diags.push(Diagnostic {
message: format!(
"`{}` writes a field of `{}`, but another key writes the whole value",
entry.conf, tk.key
),
span,
help: None,
});
continue;
}
let fields = projected.entry(tk).or_default();
let path: Vec<String> = path.iter().map(|s| s.to_string()).collect();
fields.insert(path, value.clone());
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse;
fn resolved(src: &str) -> Resolved {
let ast = parse(src).expect("parse failed");
resolve(&ast).expect("resolve failed")
}
fn errors(src: &str) -> Vec<Diagnostic> {
let ast = parse(src).expect("parse failed");
resolve(&ast).unwrap_err()
}
fn find<'a>(r: &'a Resolved, component: &str, key: &str) -> &'a WriteKind {
&r.writes
.iter()
.find(|w| w.target.component == component && w.target.key == key)
.unwrap_or_else(|| panic!("no write for {component}/{key}"))
.kind
}
/// The spec's highest-value property: two conf keys writing into one
/// composite value must fold into a single write carrying both fields.
#[test]
fn gaps_in_and_gaps_out_fold_into_one_write() {
let r = resolved("general {\n gaps_in = 3\n gaps_out = 8\n}\n");
let gap_writes: Vec<_> = r.writes.iter().filter(|w| w.target.key == "gaps").collect();
// One per theme builder — Dark and Light — and no more.
assert_eq!(gap_writes.len(), 2, "expected one folded write per builder");
for w in gap_writes {
match &w.kind {
WriteKind::Projected(fields) => {
assert_eq!(fields.len(), 2, "both halves must survive folding");
assert_eq!(fields[&vec!["1".to_string()]], Value::U32(3), "inner");
assert_eq!(fields[&vec!["0".to_string()]], Value::U32(8), "outer");
}
other => panic!("expected Projected, got {other:?}"),
}
}
}
#[test]
fn gaps_land_on_the_verified_tuple_indices() {
// (outer, inner) per theme.rs:895 — swapping these silently ruins the
// user's layout, so assert the concrete indices.
let r = resolved("general {\n gaps_in = 3\n gaps_out = 8\n}\n");
match find(&r, "com.system76.CosmicTheme.Dark.Builder", "gaps") {
WriteKind::Projected(f) => {
assert_eq!(f[&vec!["0".to_string()]], Value::U32(8));
assert_eq!(f[&vec!["1".to_string()]], Value::U32(3));
}
other => panic!("expected Projected, got {other:?}"),
}
}
#[test]
fn direct_keys_produce_whole_writes() {
let r = resolved("general {\n autotile = true\n}\n");
assert_eq!(
find(&r, "com.system76.CosmicComp", "autotile"),
&WriteKind::Whole(Value::Bool(true))
);
}
#[test]
fn variables_expand() {
let r = resolved("$gap = 5\ngeneral {\n gaps_in = $gap\n}\n");
match find(&r, "com.system76.CosmicTheme.Dark.Builder", "gaps") {
WriteKind::Projected(f) => assert_eq!(f[&vec!["1".to_string()]], Value::U32(5)),
other => panic!("expected Projected, got {other:?}"),
}
}
#[test]
fn arithmetic_on_variables_works() {
let r = resolved("$gap = 4\ngeneral {\n gaps_out = $gap * 2\n}\n");
match find(&r, "com.system76.CosmicTheme.Dark.Builder", "gaps") {
WriteKind::Projected(f) => assert_eq!(f[&vec!["0".to_string()]], Value::U32(8)),
other => panic!("expected Projected, got {other:?}"),
}
}
#[test]
fn longer_variable_names_win() {
// `$gap` must not eat the prefix of `$gaps`.
let r = resolved("$gap = 1\n$gaps = 7\ngeneral {\n gaps_in = $gaps\n}\n");
match find(&r, "com.system76.CosmicTheme.Dark.Builder", "gaps") {
WriteKind::Projected(f) => assert_eq!(f[&vec!["1".to_string()]], Value::U32(7)),
other => panic!("expected Projected, got {other:?}"),
}
}
#[test]
fn rgb_colors_parse() {
let r = resolved("theme {\n accent = rgb(6b9fed)\n}\n");
match find(&r, "com.system76.CosmicTheme.Dark.Builder", "accent") {
WriteKind::Projected(f) => {
assert_eq!(f[&Vec::<String>::new()], Value::Color(0x6b, 0x9f, 0xed, 255));
}
other => panic!("expected Projected, got {other:?}"),
}
}
/// `#` always begins a comment, so a bare hex colour is stripped before it
/// reaches the value parser. This must fail loudly rather than silently
/// yield an empty value.
#[test]
fn bare_hex_color_is_rejected_because_hash_is_a_comment() {
let d = errors("theme {\n accent = #6b9fed\n}\n");
assert_eq!(d.len(), 1);
assert!(d[0].message.contains("colour"), "{}", d[0].message);
}
#[test]
fn rgba_keeps_alpha() {
let r = resolved("theme {\n accent = rgba(6b9fed80)\n}\n");
match find(&r, "com.system76.CosmicTheme.Dark.Builder", "accent") {
WriteKind::Projected(f) => {
assert_eq!(f[&Vec::<String>::new()], Value::Color(0x6b, 0x9f, 0xed, 0x80));
}
other => panic!("expected Projected, got {other:?}"),
}
}
#[test]
fn unknown_key_suggests_the_near_miss() {
let d = errors("general {\n gaps_inn = 8\n}\n");
assert_eq!(d.len(), 1);
assert!(d[0].message.contains("unknown key"), "{}", d[0].message);
assert_eq!(d[0].help.as_deref(), Some("did you mean `general.gaps_in`?"));
assert_eq!(d[0].span.line, 2);
}
#[test]
fn type_errors_are_reported_against_the_value() {
let d = errors("general {\n gaps_in = purple\n}\n");
assert_eq!(d.len(), 1);
assert!(d[0].message.contains("non-negative integer"), "{}", d[0].message);
}
#[test]
fn out_of_range_values_are_rejected() {
let d = errors("general {\n gaps_in = 9999\n}\n");
assert!(d[0].message.contains("outside the allowed range"), "{}", d[0].message);
}
#[test]
fn all_diagnostics_are_reported_not_just_the_first() {
let d = errors("general {\n gaps_inn = 8\n autotile = maybe\n}\n");
assert_eq!(d.len(), 2, "expected both errors, got {d:?}");
}
/// Transactionality: any error means zero writes escape.
#[test]
fn a_single_error_produces_no_writes() {
let ast = parse("general {\n autotile = true\n gaps_in = nope\n}\n").unwrap();
assert!(resolve(&ast).is_err(), "must not partially apply");
}
}
+358
View File
@@ -0,0 +1,358 @@
//! Declarative registry mapping `cosmic.conf` keys onto cosmic-config targets.
//!
//! This is data, not code: adding a knob is a table row. Every fact encoded here
//! was verified against a checkout rather than assumed — see the spec's
//! "Verified findings" table for file:line evidence.
/// Scalar types a conf value can carry.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Ty {
Bool,
U32,
F32,
Str,
/// `rgb(rrggbb)` or `rgba(rrggbbaa)`. Bare `#rrggbb` is not accepted —
/// `#` begins a comment.
Color,
}
/// Where a conf key's value lands in the cosmic-config tree.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Target {
/// The conf key owns the entire cosmic-config value.
Direct {
component: &'static str,
version: u8,
key: &'static str,
},
/// The conf key owns one field within a composite value. Requires
/// read-modify-write, and multiple conf keys may share one target.
Projected {
component: &'static str,
version: u8,
key: &'static str,
path: &'static [&'static str],
},
}
impl Target {
pub fn component(&self) -> &'static str {
match self {
Target::Direct { component, .. } | Target::Projected { component, .. } => component,
}
}
pub fn version(&self) -> u8 {
match self {
Target::Direct { version, .. } | Target::Projected { version, .. } => *version,
}
}
pub fn key(&self) -> &'static str {
match self {
Target::Direct { key, .. } | Target::Projected { key, .. } => key,
}
}
}
/// Inclusive numeric bounds, checked during `resolve`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Range {
pub min: f64,
pub max: f64,
}
#[derive(Debug, Clone, Copy)]
pub struct Entry {
/// Dotted path as written in the file, e.g. `general.gaps_in`.
pub conf: &'static str,
/// One conf key may fan out to several components — Dark and Light theme
/// builders are separate cosmic-config components holding the same field.
pub targets: &'static [Target],
pub ty: Ty,
pub validate: Option<Range>,
/// Generates `cosmic.conf.default`, so the reference file cannot drift.
pub doc: &'static str,
}
const DARK_BUILDER: &str = "com.system76.CosmicTheme.Dark.Builder";
const LIGHT_BUILDER: &str = "com.system76.CosmicTheme.Light.Builder";
const COMP: &str = "com.system76.CosmicComp";
const TK: &str = "com.system76.CosmicTk";
const THEME_MODE: &str = "com.system76.CosmicTheme.Mode";
/// `ThemeBuilder.gaps` is `(u32, u32)` ordered **(outer, inner)** —
/// `cosmic-theme/src/model/theme.rs:895`. Index 0 is the outer gap.
const GAPS_OUTER_IDX: &str = "0";
const GAPS_INNER_IDX: &str = "1";
macro_rules! both_themes {
($key:literal, $path:expr) => {
&[
Target::Projected {
component: DARK_BUILDER,
version: 1,
key: $key,
path: $path,
},
Target::Projected {
component: LIGHT_BUILDER,
version: 1,
key: $key,
path: $path,
},
]
};
}
pub const REGISTRY: &[Entry] = &[
// ---- general ---------------------------------------------------------
Entry {
conf: "general.gaps_in",
targets: both_themes!("gaps", &[GAPS_INNER_IDX]),
ty: Ty::U32,
validate: Some(Range { min: 0.0, max: 128.0 }),
doc: "Gap between adjacent tiled windows, in px",
},
Entry {
conf: "general.gaps_out",
targets: both_themes!("gaps", &[GAPS_OUTER_IDX]),
ty: Ty::U32,
validate: Some(Range { min: 0.0, max: 256.0 }),
doc: "Gap between tiled windows and the screen edge, in px",
},
Entry {
conf: "general.autotile",
targets: &[Target::Direct {
component: COMP,
version: 1,
key: "autotile",
}],
ty: Ty::Bool,
validate: None,
doc: "Automatically tile new windows",
},
Entry {
conf: "general.active_hint",
targets: &[Target::Direct {
component: COMP,
version: 1,
key: "active_hint",
}],
ty: Ty::Bool,
validate: None,
doc: "Draw a hint around the focused window",
},
Entry {
conf: "general.focus_follows_cursor",
targets: &[Target::Direct {
component: COMP,
version: 1,
key: "focus_follows_cursor",
}],
ty: Ty::Bool,
validate: None,
doc: "Move keyboard focus when the cursor enters a window",
},
Entry {
conf: "general.focus_follows_cursor_delay",
targets: &[Target::Direct {
component: COMP,
version: 1,
key: "focus_follows_cursor_delay",
}],
ty: Ty::U32,
validate: Some(Range { min: 0.0, max: 5000.0 }),
doc: "Delay in ms before focus follows the cursor",
},
Entry {
conf: "general.cursor_follows_focus",
targets: &[Target::Direct {
component: COMP,
version: 1,
key: "cursor_follows_focus",
}],
ty: Ty::Bool,
validate: None,
doc: "Warp the cursor to the window that gains keyboard focus",
},
Entry {
conf: "general.edge_snap_threshold",
targets: &[Target::Direct {
component: COMP,
version: 1,
key: "edge_snap_threshold",
}],
ty: Ty::U32,
validate: Some(Range { min: 0.0, max: 256.0 }),
doc: "Distance in px at which windows snap to output edges",
},
// ---- decoration ------------------------------------------------------
Entry {
conf: "decoration.rounding",
targets: both_themes!("corner_radii", &["radius_m"]),
ty: Ty::F32,
validate: Some(Range { min: 0.0, max: 64.0 }),
doc: "Window corner radius in px (maps to the theme's radius_m)",
},
// ---- theme -----------------------------------------------------------
Entry {
conf: "theme.mode",
targets: &[Target::Direct {
component: THEME_MODE,
version: 1,
key: "is_dark",
}],
ty: Ty::Str,
validate: None,
doc: "`dark` or `light`",
},
Entry {
conf: "theme.accent",
targets: both_themes!("accent", &[]),
ty: Ty::Color,
validate: None,
doc: "Accent colour as rgb(rrggbb) or rgba(rrggbbaa)",
},
Entry {
conf: "theme.bg_color",
targets: both_themes!("bg_color", &[]),
ty: Ty::Color,
validate: None,
doc: "Background base colour",
},
Entry {
conf: "theme.icon_theme",
targets: &[Target::Direct {
component: TK,
version: 1,
key: "icon_theme",
}],
ty: Ty::Str,
validate: None,
doc: "Icon theme name, e.g. Tela-circle-dracula",
},
];
/// Exact lookup by dotted conf path.
pub fn lookup(conf: &str) -> Option<&'static Entry> {
REGISTRY.iter().find(|e| e.conf == conf)
}
/// Nearest known key by edit distance, for "did you mean" diagnostics.
/// Only suggests when the candidate is close enough to be plausible.
pub fn suggest(conf: &str) -> Option<&'static str> {
let budget = match conf.len() {
0..=4 => 1,
5..=8 => 2,
_ => 3,
};
REGISTRY
.iter()
.map(|e| (edit_distance(conf, e.conf), e.conf))
.filter(|(d, _)| *d <= budget)
.min_by_key(|(d, _)| *d)
.map(|(_, c)| c)
}
/// Levenshtein distance, two-row variant.
fn edit_distance(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
if a.is_empty() {
return b.len();
}
if b.is_empty() {
return a.len();
}
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut cur = vec![0usize; b.len() + 1];
for (i, ca) in a.iter().enumerate() {
cur[0] = i + 1;
for (j, cb) in b.iter().enumerate() {
let cost = usize::from(ca != cb);
cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
}
std::mem::swap(&mut prev, &mut cur);
}
prev[b.len()]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gaps_use_the_verified_tuple_order() {
// ThemeBuilder.gaps is (outer, inner) — theme.rs:895. Getting this
// backwards silently swaps the user's gaps, so pin it.
let inner = lookup("general.gaps_in").unwrap();
let outer = lookup("general.gaps_out").unwrap();
for t in inner.targets {
match t {
Target::Projected { path, .. } => assert_eq!(*path, &["1"]),
other => panic!("gaps_in should project, got {other:?}"),
}
}
for t in outer.targets {
match t {
Target::Projected { path, .. } => assert_eq!(*path, &["0"]),
other => panic!("gaps_out should project, got {other:?}"),
}
}
}
#[test]
fn theme_keys_fan_out_to_dark_and_light() {
let e = lookup("general.gaps_in").unwrap();
let comps: Vec<_> = e.targets.iter().map(|t| t.component()).collect();
assert!(comps.contains(&"com.system76.CosmicTheme.Dark.Builder"));
assert!(comps.contains(&"com.system76.CosmicTheme.Light.Builder"));
assert_eq!(comps.len(), 2);
}
#[test]
fn comp_keys_do_not_fan_out() {
let e = lookup("general.autotile").unwrap();
assert_eq!(e.targets.len(), 1);
assert_eq!(e.targets[0].component(), "com.system76.CosmicComp");
}
#[test]
fn every_entry_has_at_least_one_target() {
for e in REGISTRY {
assert!(!e.targets.is_empty(), "`{}` has no targets", e.conf);
}
}
#[test]
fn conf_paths_are_unique() {
let mut seen = std::collections::BTreeSet::new();
for e in REGISTRY {
assert!(seen.insert(e.conf), "duplicate registry entry `{}`", e.conf);
}
}
#[test]
fn every_entry_is_documented() {
// `doc` generates cosmic.conf.default; an empty one would ship a blank
// reference line.
for e in REGISTRY {
assert!(!e.doc.trim().is_empty(), "`{}` has no doc", e.conf);
}
}
#[test]
fn suggests_near_misses() {
assert_eq!(suggest("general.gaps_inn"), Some("general.gaps_in"));
assert_eq!(suggest("general.autotil"), Some("general.autotile"));
}
#[test]
fn does_not_suggest_nonsense() {
assert_eq!(suggest("completely.unrelated.nonsense.key"), None);
}
}
@@ -162,14 +162,39 @@ enum Target {
Entry { Entry {
conf: "general.gaps_in", conf: "general.gaps_in",
target: Projected { component: "com.system76.CosmicTk", version: 1, // Fan-out: Dark and Light are separate cosmic-config components
key: "gaps", path: &["0"] }, targets: &[
Projected { component: "com.system76.CosmicTheme.Dark.Builder", version: 1,
key: "gaps", path: &["1"] },
Projected { component: "com.system76.CosmicTheme.Light.Builder", version: 1,
key: "gaps", path: &["1"] },
],
ty: Ty::U32, ty: Ty::U32,
validate: Some(range(0..=128)), validate: Some(range(0..=128)),
doc: "Gap between adjacent tiled windows, in px", doc: "Gap between adjacent tiled windows, in px",
} }
``` ```
**Spike-corrected facts** (verified in `vendor/libcosmic`):
- `gaps: (u32, u32)` lives on `ThemeBuilder` (`cosmic-theme/src/model/theme.rs:895`), **not** `CosmicTk`.
Component IDs at `theme.rs:17-26`. Default `(0, 8)`.
- Tuple order is **`(outer, inner)`** — so `gaps_out` is index `0` and `gaps_in` is index `1`.
- Dark and Light Builders are **separate components**, so one conf key fans out to two targets.
`Entry` therefore carries `targets: &[Target]`, not a single target.
- `CosmicTk` (`libcosmic/src/config/mod.rs:14`, ID `com.system76.CosmicTk`) holds
`icon_theme`, `interface_font`, `monospace_font`, `header_size`, `interface_density`,
`show_minimize`, `show_maximize`, `apply_theme_global``icon_theme` is needed by the HyDE
importer, which sets `$ICON_THEME`.
**`emit` writes through the typed `cosmic-config` API, not raw files.** `Config::watch`
(`cosmic-config/src/lib.rs:377`) is a `notify` inotify watch on the config directory that derives
changed keys from file paths, so raw writes would in fact be observed — but `Config::set` gives
correct RON encoding per type, atomic writes via `atomicwrites::AtomicFile` (`lib.rs:513`), and
matches the watcher's `.atomicwrite` temp-file filter (`lib.rs:408`). cosmic-conf therefore
depends on `cosmic-theme`, `cosmic-comp-config` and `cosmic-settings-config` for the concrete
types, which also buys compile-time type checking of the registry.
**Critical correctness property:** projected writes are read-modify-write, and multiple conf keys **Critical correctness property:** projected writes are read-modify-write, and multiple conf keys
can share one target. `emit` MUST group by target key, fold all projections, then write once. can share one target. `emit` MUST group by target key, fold all projections, then write once.
Naïve per-key writes let `gaps_out` clobber `gaps_in`. This is directly unit-testable and is the Naïve per-key writes let `gaps_out` clobber `gaps_in`. This is directly unit-testable and is the