1use std::collections::BTreeSet;
30
31use crate::action_expand::{collect_bindings, rename_bindings};
32use crate::body::parse_rule_body;
33use crate::body_print::print_statement_rn;
34use crate::{
35 binding_after_as, format_description, format_item, format_tags, format_workflow, lex_comments,
36 parse_program, push_line, split_when_guard, stable_hash, Item, RuleDecl, WhenClause,
37};
38
39#[derive(Clone, Debug, Eq, PartialEq)]
41pub struct DeclCanon {
42 pub identity: String,
45 pub canon_hash: String,
48 pub rename_hash: String,
53 pub alpha: bool,
56}
57
58const CANON_PREFIX: &str = "wsc__";
62
63pub fn canonical_declarations(source: &str) -> Option<Vec<DeclCanon>> {
70 let stripped = strip_comments(source);
71 let parsed = parse_program(&stripped);
72 if !parsed.diagnostics.is_empty() {
73 return None;
74 }
75 let program = parsed.program;
76
77 let mut chunks: Vec<(String, bool)> = Vec::new();
78 if let Some(workflow) = program.workflow {
79 let mut chunk = String::new();
80 format_tags(&program.workflow_tags, &mut chunk);
81 format_description(program.workflow_description.as_ref(), &mut chunk);
82 push_line(&mut chunk, format!("workflow {}", workflow.name));
83 chunks.push((chunk, true));
84 }
85 let mut top_level: Vec<Item> = Vec::new();
86 top_level.extend(program.patterns.into_iter().map(Item::Pattern));
87 top_level.extend(program.items);
88 for item in top_level {
89 chunks.push(canonical_item_chunk(item));
90 }
91 for mut workflow in program.workflows {
92 let mut alpha = true;
93 workflow.items = workflow
94 .items
95 .into_iter()
96 .map(|item| match item {
97 Item::Rule(rule) => {
98 let (rule, applied) = alpha_rule(rule);
99 alpha &= applied;
100 Item::Rule(rule)
101 }
102 other => other,
103 })
104 .collect();
105 let mut chunk = String::new();
106 format_workflow(workflow, &mut chunk);
107 chunks.push((chunk, alpha));
108 }
109
110 let mut seen = BTreeSet::new();
111 let mut declarations = Vec::with_capacity(chunks.len());
112 for (chunk, alpha) in chunks {
113 let canonical = normalize_chunk(&chunk);
114 if canonical.is_empty() {
115 continue;
116 }
117 let identity = identity_of(&canonical)?;
118 if !seen.insert(identity.clone()) {
119 return None;
120 }
121 let rename_hash = stable_hash(&name_normalized(&canonical, &identity));
122 declarations.push(DeclCanon {
123 identity,
124 canon_hash: stable_hash(&canonical),
125 rename_hash,
126 alpha,
127 });
128 }
129 Some(declarations)
130}
131
132pub fn canonical_program_hash(source: &str) -> Option<String> {
137 let mut declarations = canonical_declarations(source)?;
138 declarations.sort_by(|a, b| a.identity.cmp(&b.identity));
139 let mut manifest = String::new();
140 for declaration in &declarations {
141 manifest.push_str(&declaration.identity);
142 manifest.push('\t');
143 manifest.push_str(&declaration.canon_hash);
144 manifest.push('\n');
145 }
146 Some(stable_hash(&manifest))
147}
148
149fn canonical_item_chunk(item: Item) -> (String, bool) {
150 let (item, alpha) = match item {
151 Item::Rule(rule) => {
152 let (rule, applied) = alpha_rule(rule);
153 (Item::Rule(rule), applied)
154 }
155 other => (other, true),
156 };
157 let mut chunk = String::new();
158 format_item(item, &mut chunk);
159 (chunk, alpha)
160}
161
162fn strip_comments(source: &str) -> String {
166 let comments = lex_comments(source);
167 if comments.is_empty() {
168 return source.to_owned();
169 }
170 let mut stripped = String::with_capacity(source.len());
171 let mut cursor = 0;
172 let mut spans: Vec<_> = comments.iter().map(|comment| comment.span).collect();
173 spans.sort_by_key(|span| span.start);
174 for span in spans {
175 if span.start < cursor {
176 continue;
177 }
178 stripped.push_str(&source[cursor..span.start]);
179 cursor = span.end.max(span.start);
180 }
181 stripped.push_str(&source[cursor..]);
182 stripped
183}
184
185fn normalize_chunk(chunk: &str) -> String {
189 let mut normalized = String::with_capacity(chunk.len());
190 for line in chunk.lines() {
191 let trimmed = line.trim_end();
192 if trimmed.is_empty() {
193 continue;
194 }
195 normalized.push_str(trimmed);
196 normalized.push('\n');
197 }
198 normalized
199}
200
201fn identity_of(canonical: &str) -> Option<String> {
205 canonical
206 .lines()
207 .find(|line| !line.starts_with('@') && !line.starts_with('"'))
208 .map(|line| line.trim_end().trim_end_matches('{').trim_end().to_owned())
209}
210
211fn name_normalized(canonical: &str, identity: &str) -> String {
218 let Some(name) = identity.split_whitespace().last() else {
219 return canonical.to_owned();
220 };
221 let mut out = String::with_capacity(canonical.len());
222 for (index, line) in canonical.lines().enumerate() {
223 let is_header = canonical
224 .lines()
225 .position(|candidate| !candidate.starts_with('@') && !candidate.starts_with('"'))
226 == Some(index);
227 if is_header {
228 if let Some(position) = line.rfind(name) {
229 out.push_str(&line[..position]);
230 out.push('_');
231 out.push_str(&line[position + name.len()..]);
232 } else {
233 out.push_str(line);
234 }
235 } else {
236 out.push_str(line);
237 }
238 out.push('\n');
239 }
240 out
241}
242
243fn alpha_rule(rule: RuleDecl) -> (RuleDecl, bool) {
248 match try_alpha_rule(&rule) {
249 Some(renamed) => (renamed, true),
250 None => (rule, false),
251 }
252}
253
254fn try_alpha_rule(rule: &RuleDecl) -> Option<RuleDecl> {
255 let full_text = format!(
257 "{}\n{}",
258 rule.whens
259 .iter()
260 .map(|when| when.text.as_str())
261 .collect::<Vec<_>>()
262 .join("\n"),
263 rule.body.text
264 );
265 if full_text.contains(CANON_PREFIX) {
266 return None;
267 }
268
269 let (mut ast, diagnostics) = parse_rule_body(&rule.body.text, rule.body.span.start);
270 if !diagnostics.is_empty() {
271 return None;
272 }
273 let identity_renamer = |text: &str| text.to_owned();
278 let mut reprinted = String::new();
279 for statement in &ast.statements {
280 print_statement_rn(statement, 0, &identity_renamer, &mut reprinted);
281 }
282 if normalize_body(&reprinted) != normalize_body(&rule.body.text) {
283 return None;
284 }
285
286 let mut bindings: Vec<String> = Vec::new();
288 for when in &rule.whens {
289 let (pattern, _) = split_when_guard(&when.text);
290 if let Some(binding) = binding_after_as(pattern) {
291 if !bindings.contains(&binding) {
292 bindings.push(binding);
293 }
294 }
295 }
296 let mut body_bindings = Vec::new();
297 collect_bindings(&ast.statements, &mut body_bindings);
298 for binding in body_bindings {
299 if !bindings.contains(&binding) {
300 bindings.push(binding);
301 }
302 }
303 if bindings.is_empty() {
304 return Some(rule.clone());
305 }
306
307 let renames: Vec<(String, String)> = bindings
308 .iter()
309 .enumerate()
310 .map(|(index, binding)| (binding.clone(), format!("{CANON_PREFIX}{index}")))
311 .collect();
312
313 let mut whens = Vec::with_capacity(rule.whens.len());
317 for when in &rule.whens {
318 let (pattern, guard) = split_when_guard(&when.text);
319 let mut new_pattern = pattern.to_owned();
320 for (from, to) in &renames {
321 let occurrences = count_word(&new_pattern, from);
322 if occurrences == 0 {
323 continue;
324 }
325 let intro = format!("as {from}");
326 if occurrences != 1 || !new_pattern.contains(&intro) {
327 return None;
328 }
329 new_pattern = new_pattern.replace(&intro, &format!("as {to}"));
330 }
331 let new_text = match guard {
332 Some(guard) => {
333 let mut renamed_guard = guard.to_owned();
334 for (from, to) in &renames {
335 renamed_guard = rename_reference(&renamed_guard, from, to);
336 }
337 format!("{new_pattern} where {renamed_guard}")
338 }
339 None => new_pattern,
340 };
341 whens.push(WhenClause {
342 text: new_text,
343 span: when.span,
344 });
345 }
346
347 rename_bindings(&mut ast.statements, &renames);
351 let value_renames = renames.clone();
352 let renamer = move |text: &str| {
353 let mut current = text.to_owned();
354 for (from, to) in &value_renames {
355 current = rename_reference(¤t, from, to);
356 }
357 current
358 };
359 let mut body = String::new();
360 for statement in &ast.statements {
361 print_statement_rn(statement, 0, &renamer, &mut body);
362 }
363
364 let mut renamed = rule.clone();
365 renamed.whens = whens;
366 renamed.body.text = body;
367 Some(renamed)
368}
369
370fn normalize_body(body: &str) -> String {
371 let mut normalized = String::with_capacity(body.len());
372 for line in body.lines() {
373 let trimmed = line.trim();
374 if trimmed.is_empty() {
375 continue;
376 }
377 normalized.push_str(trimmed);
378 normalized.push('\n');
379 }
380 normalized
381}
382
383fn count_word(text: &str, word: &str) -> usize {
384 let bytes = text.as_bytes();
385 let needle = word.as_bytes();
386 let mut count = 0;
387 let mut index = 0;
388 while index + needle.len() <= bytes.len() {
389 let at_start = index == 0
390 || !(bytes[index - 1].is_ascii_alphanumeric()
391 || bytes[index - 1] == b'_'
392 || bytes[index - 1] == b'.');
393 if at_start
394 && bytes[index..].starts_with(needle)
395 && !bytes
396 .get(index + needle.len())
397 .is_some_and(|next| next.is_ascii_alphanumeric() || *next == b'_')
398 {
399 count += 1;
400 index += needle.len();
401 continue;
402 }
403 index += 1;
404 }
405 count
406}
407
408fn rename_reference(source: &str, binding: &str, replacement: &str) -> String {
415 let mut out = String::with_capacity(source.len());
416 let bytes = source.as_bytes();
417 let needle = binding.as_bytes();
418 let mut index = 0;
419 let mut in_string = false;
420 let mut in_template = false;
421 while index < bytes.len() {
422 if bytes[index..].starts_with(b"{{") {
423 in_template = true;
424 out.push_str("{{");
425 index += 2;
426 continue;
427 }
428 if bytes[index..].starts_with(b"}}") {
429 in_template = false;
430 out.push_str("}}");
431 index += 2;
432 continue;
433 }
434 if bytes[index] == b'"' && !in_template {
435 in_string = !in_string;
436 out.push('"');
437 index += 1;
438 continue;
439 }
440 let renameable = !in_string || in_template;
441 let at_word_start = index == 0
442 || !(bytes[index - 1].is_ascii_alphanumeric()
443 || bytes[index - 1] == b'_'
444 || bytes[index - 1] == b'.');
445 if renameable
446 && at_word_start
447 && bytes[index..].starts_with(needle)
448 && !bytes
449 .get(index + needle.len())
450 .is_some_and(|next| next.is_ascii_alphanumeric() || *next == b'_')
451 {
452 out.push_str(replacement);
453 index += needle.len();
454 continue;
455 }
456 out.push(bytes[index] as char);
457 index += 1;
458 }
459 out
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465
466 const BASE: &str = "workflow Demo\n\noutput result Report\n\nclass Report {\n message string\n}\n\nclass Ticket {\n status string\n}\n\nrule triage\n when started\n=> {\n record Ticket {\n status \"open\"\n }\n}\n\nrule close\n when Ticket as t where t.status == \"open\"\n=> {\n complete result {\n message \"done: {{ t.status }}\"\n }\n}\n";
467
468 #[test]
469 fn formatting_comments_and_binding_names_share_a_canon_class() {
470 let base = canonical_declarations(BASE).expect("canonical");
471 let noisy = BASE
473 .replace("rule close\n", "# closes the ticket\nrule close\n")
474 .replace(" as t where t.status", " as ticket where ticket.status")
475 .replace("{{ t.status }}", "{{ ticket.status }}")
476 .replace(" message string", " message string");
477 let noisy_canon = canonical_declarations(&noisy).expect("canonical");
478 assert_eq!(base, noisy_canon);
479 assert_eq!(canonical_program_hash(BASE), canonical_program_hash(&noisy));
480 }
481
482 #[test]
483 fn semantic_edits_change_the_canon_hash() {
484 let edited = BASE.replace("status \"open\"", "status \"reopened\"");
485 let base = canonical_declarations(BASE).expect("canonical");
486 let after = canonical_declarations(&edited).expect("canonical");
487 let hash_of = |declarations: &[DeclCanon], identity: &str| {
488 declarations
489 .iter()
490 .find(|declaration| declaration.identity == identity)
491 .map(|declaration| declaration.canon_hash.clone())
492 };
493 assert_ne!(
494 hash_of(&base, "rule triage"),
495 hash_of(&after, "rule triage")
496 );
497 assert_eq!(hash_of(&base, "rule close"), hash_of(&after, "rule close"));
498 }
499
500 #[test]
501 fn field_named_like_a_binding_never_collapses() {
502 let with_status_binding = "workflow Demo\n\nclass Ticket {\n status string\n}\n\nrule watch\n when Ticket as status\n=> {\n record Ticket {\n status \"seen: {{ status.status }}\"\n }\n}\n";
507 let with_other_field =
508 with_status_binding.replace("{{ status.status }}", "{{ status.id }}");
509 let a = canonical_declarations(with_status_binding).expect("canonical");
510 let b = canonical_declarations(&with_other_field).expect("canonical");
511 let rule_a = a.iter().find(|d| d.identity == "rule watch").unwrap();
512 let rule_b = b.iter().find(|d| d.identity == "rule watch").unwrap();
513 assert_ne!(rule_a.canon_hash, rule_b.canon_hash);
514 }
515
516 #[test]
517 fn rename_hash_matches_across_a_pure_rename_only() {
518 let renamed = BASE.replace("rule close\n", "rule closed_out\n");
519 let base = canonical_declarations(BASE).expect("canonical");
520 let after = canonical_declarations(&renamed).expect("canonical");
521 let close = base.iter().find(|d| d.identity == "rule close").unwrap();
522 let closed_out = after
523 .iter()
524 .find(|d| d.identity == "rule closed_out")
525 .unwrap();
526 assert_ne!(close.canon_hash, closed_out.canon_hash);
527 assert_eq!(close.rename_hash, closed_out.rename_hash);
528
529 let rename_and_edit =
531 renamed.replace("message \"done: {{ t.status }}\"", "message \"finished\"");
532 let edited = canonical_declarations(&rename_and_edit).expect("canonical");
533 let edited_rule = edited
534 .iter()
535 .find(|d| d.identity == "rule closed_out")
536 .unwrap();
537 assert_ne!(close.rename_hash, edited_rule.rename_hash);
538 }
539
540 #[test]
541 fn unparseable_source_has_no_canonical_form() {
542 assert_eq!(canonical_declarations("not whip at all"), None);
543 assert_eq!(canonical_program_hash("rule {"), None);
544 }
545
546 #[test]
547 fn reserved_namespace_degrades_alpha_not_correctness() {
548 let reserved = BASE
549 .replace(" as t where t.status", " as wsc__9 where wsc__9.status")
550 .replace("{{ t.status }}", "{{ wsc__9.status }}");
551 let declarations = canonical_declarations(&reserved).expect("canonical");
552 let rule = declarations
553 .iter()
554 .find(|declaration| declaration.identity == "rule close")
555 .unwrap();
556 assert!(!rule.alpha);
557 }
558}