1use proc_macro::TokenStream;
8use std::{collections::BTreeMap, str::FromStr};
9
10#[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#[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}