From 09f90f65defbe1203a2cb072d7277cac9eed328b Mon Sep 17 00:00:00 2001 From: dingo Date: Sun, 9 Aug 2026 22:15:50 +0700 Subject: [PATCH] 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. --- .gitignore | 3 +- cosmic-conf/Cargo.lock | 7 + cosmic-conf/Cargo.toml | 15 + cosmic-conf/src/lib.rs | 83 +++ cosmic-conf/src/parser.rs | 338 ++++++++++++ cosmic-conf/src/resolve.rs | 493 ++++++++++++++++++ cosmic-conf/src/schema.rs | 358 +++++++++++++ .../specs/2026-08-09-hyprcosmic-design.md | 29 +- 8 files changed, 1323 insertions(+), 3 deletions(-) create mode 100644 cosmic-conf/Cargo.lock create mode 100644 cosmic-conf/Cargo.toml create mode 100644 cosmic-conf/src/lib.rs create mode 100644 cosmic-conf/src/parser.rs create mode 100644 cosmic-conf/src/resolve.rs create mode 100644 cosmic-conf/src/schema.rs diff --git a/.gitignore b/.gitignore index 7997961..cd0766b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ .omc/ # Rust -/target/ +target/ **/*.rs.bk +vendor/ diff --git a/cosmic-conf/Cargo.lock b/cosmic-conf/Cargo.lock new file mode 100644 index 0000000..6779b99 --- /dev/null +++ b/cosmic-conf/Cargo.lock @@ -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" diff --git a/cosmic-conf/Cargo.toml b/cosmic-conf/Cargo.toml new file mode 100644 index 0000000..0332947 --- /dev/null +++ b/cosmic-conf/Cargo.toml @@ -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 = [] diff --git a/cosmic-conf/src/lib.rs b/cosmic-conf/src/lib.rs new file mode 100644 index 0000000..a24de1d --- /dev/null +++ b/cosmic-conf/src/lib.rs @@ -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}"); + } +} diff --git a/cosmic-conf/src/parser.rs b/cosmic-conf/src/parser.rs new file mode 100644 index 0000000..b23f813 --- /dev/null +++ b/cosmic-conf/src/parser.rs @@ -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 { + pub value: T, + pub span: Span, +} + +impl Spanned { + pub fn new(value: T, span: Span) -> Self { + Self { value, span } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Item { + /// `$name = value` + VarDef { + name: Spanned, + value: Spanned, + }, + /// `key = value` inside the current section + Assign { + key: Spanned, + value: Spanned, + }, + /// `name { .. }` + Section { + name: Spanned, + items: Vec, + }, + /// `source = path` + Source { path: Spanned }, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Ast { + pub items: Vec, +} + +#[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 { + 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, 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); + } +} diff --git a/cosmic-conf/src/resolve.rs b/cosmic-conf/src/resolve.rs new file mode 100644 index 0000000..58a92f0 --- /dev/null +++ b/cosmic-conf/src/resolve.rs @@ -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, +} + +/// 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, Value>), +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Write { + pub target: TargetKey, + pub kind: WriteKind, +} + +#[derive(Debug, Default)] +pub struct Resolved { + pub writes: Vec, +} + +/// 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, 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) { + 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 { + 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::(), r.parse::()) { + 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 { + 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 { + 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::() + .map(Value::U32) + .map_err(|_| bad("a non-negative integer")), + Ty::F32 => raw + .parse::() + .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, 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> { + 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, Value>> = BTreeMap::new(); + let mut whole: BTreeMap = 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 = 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, Value>>, + whole: &mut BTreeMap, + diags: &mut Vec, +) { + 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 = 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 { + 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::::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::::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"); + } +} diff --git a/cosmic-conf/src/schema.rs b/cosmic-conf/src/schema.rs new file mode 100644 index 0000000..cf92faf --- /dev/null +++ b/cosmic-conf/src/schema.rs @@ -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, + /// 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 = a.chars().collect(); + let b: Vec = b.chars().collect(); + if a.is_empty() { + return b.len(); + } + if b.is_empty() { + return a.len(); + } + + let mut prev: Vec = (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); + } +} diff --git a/docs/superpowers/specs/2026-08-09-hyprcosmic-design.md b/docs/superpowers/specs/2026-08-09-hyprcosmic-design.md index bc393b5..3eeb47f 100644 --- a/docs/superpowers/specs/2026-08-09-hyprcosmic-design.md +++ b/docs/superpowers/specs/2026-08-09-hyprcosmic-design.md @@ -162,14 +162,39 @@ enum Target { Entry { conf: "general.gaps_in", - target: Projected { component: "com.system76.CosmicTk", version: 1, - key: "gaps", path: &["0"] }, + // Fan-out: Dark and Light are separate cosmic-config components + 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, validate: Some(range(0..=128)), 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 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