mirror of
https://github.com/outbackdingo/hyprcosmic.git
synced 2026-08-25 14:53:21 +00:00
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:
+2
-1
@@ -2,5 +2,6 @@
|
||||
.omc/
|
||||
|
||||
# Rust
|
||||
/target/
|
||||
target/
|
||||
**/*.rs.bk
|
||||
vendor/
|
||||
|
||||
Generated
+7
@@ -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"
|
||||
@@ -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 = []
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user