Skip to main content

omena_meta_macros/
lib.rs

1//! Compile-time metadata attributes for Omena CSS spec and transform surfaces.
2//!
3//! These macros intentionally validate only local attribute shape. Cross-item
4//! checks such as global pass ordinal continuity belong to the generated
5//! manifest/spec-audit layer that consumes these attributes.
6
7use proc_macro::TokenStream;
8use std::{collections::BTreeMap, str::FromStr};
9
10/// Attach CSS specification metadata to a syntax or semantic item.
11///
12/// Supported forms:
13///
14/// ```ignore
15/// #[spec(webref = "css-color/properties/color", priority = "P0")]
16/// #[spec(na = "print-margin-descriptor")]
17/// ```
18#[proc_macro_attribute]
19pub fn spec(attr: TokenStream, item: TokenStream) -> TokenStream {
20    match validate_spec_attr(attr.to_string().as_str()) {
21        Ok(()) => item,
22        Err(error) => item_with_compile_error(item, error.as_str()),
23    }
24}
25
26/// Attach transform-pass metadata to a pass item.
27///
28/// Supported form:
29///
30/// ```ignore
31/// #[pass(id = "color-compression", ordinal = 5, layer = "value-normalization")]
32/// ```
33#[proc_macro_attribute]
34pub fn pass(attr: TokenStream, item: TokenStream) -> TokenStream {
35    match validate_pass_attr(attr.to_string().as_str()) {
36        Ok(()) => item,
37        Err(error) => item_with_compile_error(item, error.as_str()),
38    }
39}
40
41fn validate_spec_attr(input: &str) -> Result<(), String> {
42    let args = parse_meta_args(input)?;
43    let webref = args.get("webref");
44    let not_applicable = args.get("na");
45    if webref.is_some() == not_applicable.is_some() {
46        return Err("spec requires exactly one of `webref` or `na`".to_string());
47    }
48    if let Some(value) = webref {
49        validate_webref(value)?;
50        let priority = args
51            .get("priority")
52            .ok_or_else(|| "spec with `webref` requires `priority`".to_string())?;
53        validate_priority(priority)?;
54    }
55    if let Some(value) = not_applicable {
56        validate_not_applicable(value)?;
57    }
58    reject_unknown_keys(args.keys(), &["webref", "na", "priority", "since"])?;
59    Ok(())
60}
61
62fn validate_pass_attr(input: &str) -> Result<(), String> {
63    let args = parse_meta_args(input)?;
64    let id = args
65        .get("id")
66        .ok_or_else(|| "pass requires `id`".to_string())?;
67    let ordinal = args
68        .get("ordinal")
69        .ok_or_else(|| "pass requires `ordinal`".to_string())?;
70    let layer = args
71        .get("layer")
72        .ok_or_else(|| "pass requires `layer`".to_string())?;
73    validate_pass_id(id)?;
74    validate_ordinal(ordinal)?;
75    validate_layer(layer)?;
76    reject_unknown_keys(args.keys(), &["id", "ordinal", "layer", "requires"])?;
77    Ok(())
78}
79
80fn parse_meta_args(input: &str) -> Result<BTreeMap<String, String>, String> {
81    let mut args = BTreeMap::new();
82    for segment in split_meta_segments(input) {
83        let trimmed = segment.trim();
84        if trimmed.is_empty() {
85            continue;
86        }
87        let Some((raw_key, raw_value)) = trimmed.split_once('=') else {
88            return Err(format!(
89                "metadata argument `{trimmed}` must use `key = value`"
90            ));
91        };
92        let key = raw_key.trim();
93        if !is_ident_key(key) {
94            return Err(format!("metadata key `{key}` is not supported"));
95        }
96        if args.contains_key(key) {
97            return Err(format!("metadata key `{key}` is duplicated"));
98        }
99        args.insert(key.to_string(), parse_meta_value(raw_value.trim())?);
100    }
101    Ok(args)
102}
103
104fn split_meta_segments(input: &str) -> Vec<String> {
105    let mut segments = Vec::new();
106    let mut current = String::new();
107    let mut in_string = false;
108    let mut escaped = false;
109    for char in input.chars() {
110        if in_string {
111            current.push(char);
112            if escaped {
113                escaped = false;
114            } else if char == '\\' {
115                escaped = true;
116            } else if char == '"' {
117                in_string = false;
118            }
119            continue;
120        }
121        match char {
122            '"' => {
123                in_string = true;
124                current.push(char);
125            }
126            ',' => {
127                segments.push(current);
128                current = String::new();
129            }
130            _ => current.push(char),
131        }
132    }
133    segments.push(current);
134    segments
135}
136
137fn parse_meta_value(raw_value: &str) -> Result<String, String> {
138    if raw_value.starts_with('"') {
139        if !raw_value.ends_with('"') || raw_value.len() < 2 {
140            return Err(format!("metadata string `{raw_value}` is unterminated"));
141        }
142        return Ok(raw_value[1..raw_value.len() - 1].to_string());
143    }
144    if raw_value.is_empty() || raw_value.chars().any(char::is_whitespace) {
145        return Err(format!(
146            "metadata bare value `{raw_value}` is not supported"
147        ));
148    }
149    Ok(raw_value.to_string())
150}
151
152fn validate_webref(value: &str) -> Result<(), String> {
153    if value.is_empty() || !value.contains('/') || value.chars().any(char::is_whitespace) {
154        return Err("spec `webref` must be a non-empty path-like identifier".to_string());
155    }
156    Ok(())
157}
158
159fn validate_not_applicable(value: &str) -> Result<(), String> {
160    if value.is_empty() || value.chars().any(char::is_whitespace) {
161        return Err("spec `na` must be a non-empty identifier".to_string());
162    }
163    Ok(())
164}
165
166fn validate_priority(value: &str) -> Result<(), String> {
167    match value {
168        "P0" | "P1" | "P2" | "P3" => Ok(()),
169        _ => Err("spec `priority` must be one of P0, P1, P2, or P3".to_string()),
170    }
171}
172
173fn validate_pass_id(value: &str) -> Result<(), String> {
174    if is_kebab_identifier(value) {
175        Ok(())
176    } else {
177        Err("pass `id` must be a lowercase kebab-case identifier".to_string())
178    }
179}
180
181fn validate_ordinal(value: &str) -> Result<(), String> {
182    if value.parse::<u16>().is_ok() {
183        Ok(())
184    } else {
185        Err("pass `ordinal` must be an unsigned integer".to_string())
186    }
187}
188
189fn validate_layer(value: &str) -> Result<(), String> {
190    if is_kebab_identifier(value) {
191        Ok(())
192    } else {
193        Err("pass `layer` must be a lowercase kebab-case identifier".to_string())
194    }
195}
196
197fn reject_unknown_keys<'a>(
198    keys: impl Iterator<Item = &'a String>,
199    allowed: &[&str],
200) -> Result<(), String> {
201    for key in keys {
202        if !allowed.contains(&key.as_str()) {
203            return Err(format!("metadata key `{key}` is not supported here"));
204        }
205    }
206    Ok(())
207}
208
209fn is_ident_key(value: &str) -> bool {
210    let mut chars = value.chars();
211    let Some(first) = chars.next() else {
212        return false;
213    };
214    (first.is_ascii_alphabetic() || first == '_')
215        && chars.all(|char| char.is_ascii_alphanumeric() || char == '_')
216}
217
218fn is_kebab_identifier(value: &str) -> bool {
219    if value.is_empty() || value.starts_with('-') || value.ends_with('-') {
220        return false;
221    }
222    value
223        .chars()
224        .all(|char| char.is_ascii_lowercase() || char.is_ascii_digit() || char == '-')
225}
226
227fn item_with_compile_error(item: TokenStream, message: &str) -> TokenStream {
228    let escaped = message.replace('\\', "\\\\").replace('"', "\\\"");
229    let compile_error = format!("compile_error!(\"{escaped}\");");
230    let mut output =
231        TokenStream::from_str(compile_error.as_str()).unwrap_or_else(|_| TokenStream::new());
232    output.extend(item);
233    output
234}
235
236#[cfg(test)]
237mod tests {
238    use super::{validate_pass_attr, validate_spec_attr};
239
240    fn validation_error(result: Result<(), String>) -> String {
241        match result {
242            Ok(()) => "validation unexpectedly passed".to_string(),
243            Err(error) => error,
244        }
245    }
246
247    #[test]
248    fn accepts_webref_spec_metadata() {
249        assert!(
250            validate_spec_attr(r#"webref = "css-color/properties/color", priority = "P0""#).is_ok()
251        );
252    }
253
254    #[test]
255    fn accepts_not_applicable_spec_metadata() {
256        assert!(validate_spec_attr(r#"na = "print-margin-descriptor""#).is_ok());
257    }
258
259    #[test]
260    fn rejects_spec_metadata_without_single_source() {
261        let error = validation_error(validate_spec_attr(
262            r#"webref = "css-color/properties/color", na = "manual", priority = "P0""#,
263        ));
264        assert!(error.contains("exactly one"));
265    }
266
267    #[test]
268    fn rejects_webref_spec_without_priority() {
269        let error = validation_error(validate_spec_attr(
270            r#"webref = "css-color/properties/color""#,
271        ));
272        assert!(error.contains("priority"));
273    }
274
275    #[test]
276    fn accepts_pass_metadata() {
277        assert!(
278            validate_pass_attr(
279                r#"id = "color-compression", ordinal = 5, layer = "value-normalization""#
280            )
281            .is_ok()
282        );
283    }
284
285    #[test]
286    fn rejects_non_kebab_pass_id() {
287        let error = validation_error(validate_pass_attr(
288            r#"id = "ColorCompression", ordinal = 5, layer = "value-normalization""#,
289        ));
290        assert!(error.contains("kebab-case"));
291    }
292
293    #[test]
294    fn rejects_non_numeric_pass_ordinal() {
295        let error = validation_error(validate_pass_attr(
296            r#"id = "color-compression", ordinal = "fifth", layer = "value-normalization""#,
297        ));
298        assert!(error.contains("ordinal"));
299    }
300}