Skip to main content

whipplescript_parser/
canonical.rs

1//! DR-0054 alpha-equivalence canonicalization: the one declaration-identity
2//! answer serving merge refinement, evidence keying, and declaration-level
3//! attribution (modeled in alpha-canonicalization.maude).
4//!
5//! Two relations, never one: **identity** is kind + top-level name (the
6//! normalized header line merge already keys by — a rename is a detected
7//! event, not something the scheme survives invisibly), and
8//! **content-equivalence** is the canonical hash. The canonical form is:
9//!
10//! - **L1 format**: the `whip fmt` printer's output (reindent, fixed clause
11//!   order for AST-rebuilt bodies), with blank lines dropped;
12//! - **L2 comments**: stripped lexically before parsing;
13//! - **L3 alpha**: rule-local bindings renamed positionally (`wsc__0`,
14//!   `wsc__1`, … in binding-site order: `when … as` intros first, then body
15//!   bindings). Renaming rides the structured machinery built for `action`
16//!   hygiene (`rename_bindings` for definitions and `after` references,
17//!   `print_statement_rn` so field and schema names are never touched) plus
18//!   a dot-guarded reference renamer for `when` guards — a rename must never
19//!   collapse two semantically different declarations into one hash, so
20//!   every uncertainty degrades that declaration to L1+L2 instead
21//!   (deterministic per content: a depth mismatch between two sides only
22//!   ever produces false *inequality*, never a bogus certificate);
23//! - **L4 order**: free where the formatter rebuilds a declaration block
24//!   from the AST in fixed clause order; rule `when` order stays significant.
25//!
26//! A source that does not parse has no canonical form (`None`) — every
27//! client falls back to its byte-level behavior, fail-closed.
28
29use 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/// One declaration's canonical identity and content hash.
40#[derive(Clone, Debug, Eq, PartialEq)]
41pub struct DeclCanon {
42    /// The normalized header line — merge's identity key (`rule triage`,
43    /// `class Report`, `use std.vcs`).
44    pub identity: String,
45    /// SHA-256/128 over the canonical print (blank lines dropped, lines
46    /// trailing-trimmed).
47    pub canon_hash: String,
48    /// SHA-256/128 over the canonical print with the header line's NAME
49    /// replaced by `_` — the rename-detection key (Decision 3): a deleted
50    /// and an added declaration of the same kind whose `rename_hash`es are
51    /// identical are one declaration renamed.
52    pub rename_hash: String,
53    /// Whether the L3 alpha pass applied (false = degraded to L1+L2 for
54    /// this declaration; still deterministic per content).
55    pub alpha: bool,
56}
57
58/// The reserved positional-binding namespace. A source that already uses it
59/// anywhere in a rule refuses alpha for that rule rather than risking
60/// capture.
61const CANON_PREFIX: &str = "wsc__";
62
63/// Canonicalize every top-level declaration of `source`. `None` when the
64/// source does not parse or the declaration identities are ambiguous
65/// (duplicates) — callers fail closed to their pre-DR behavior. A
66/// multi-workflow program's `workflow X { … }` blocks are single units
67/// (matching merge's depth-0 split), with alpha applied to their nested
68/// rules.
69pub 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
132/// The canonical program hash: SHA-256/128 over the sorted
133/// `(identity, canon_hash)` pairs — insensitive to formatting, comments,
134/// declaration order, and rule-binding names. `None` when the source has no
135/// canonical form.
136pub 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
162/// Remove comments lexically (the lexer is string-aware, so `#` inside a
163/// prompt string survives). Whole-line comments leave blank lines, which
164/// `normalize_chunk` drops.
165fn 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
185/// Canonical text normalization: trailing-trim every line, drop blank lines
186/// (blank placement is formatting, and stripped whole-line comments leave
187/// blanks behind).
188fn 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
201/// The identity is the declaration's header line: the first canonical line
202/// that is not a tag or description, with any trailing `{` normalized away —
203/// byte-compatible with merge's `DeclBlock.identity`.
204fn 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
211/// The canonical text with the header line's NAME token (the identity's
212/// last whitespace token) replaced by `_` — the rename-detection key.
213/// Header-line-only: a body mentioning the declaration's own name is not a
214/// pattern the language has (rules and classes do not self-reference), and
215/// leaving the body untouched keeps the key conservative — a missed match
216/// only forfeits a carry, never fabricates one.
217fn 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
243/// Positionally rename a rule's local bindings. Returns the (possibly
244/// rewritten) rule and whether alpha applied; every uncertainty returns the
245/// rule unchanged with `false` — degrading to L1+L2 is always sound, a
246/// wrong rename never is.
247fn 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    // The reserved namespace must be absent from the whole rule.
256    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    // Losslessness gate: the body parser + printer must round-trip the
274    // original text (modulo the same whitespace normalization the canonical
275    // hash applies). A body the printer cannot faithfully reproduce refuses
276    // alpha — a lossy reprint could collapse two different rules.
277    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    // Binding-site order: `when … as` intros first, then body bindings.
287    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    // `when` clauses: the pattern part may mention a binding ONLY as its
314    // `as <binding>` intro (a binding shadowing a sugar word or schema name
315    // in pattern position refuses alpha); the guard renames dot-guarded.
316    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    // Body: structured rename for definitions and `after` references, the
348    // dot-guarded reference renamer for value/expression positions (field
349    // and schema names are emitted verbatim by the printer).
350    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(&current, 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
408/// Whole-word reference rename like `body_print::rename_text`, with one
409/// extra guard: an occurrence preceded by `.` is a FIELD position
410/// (`t.status`), never a binding reference — renaming it would collapse two
411/// semantically different declarations, the one direction canonicalization
412/// must never err in. String-literal content is preserved except inside
413/// `{{ … }}` template interpolations, where bindings are real references.
414fn 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        // Reformat + comment + binding rename: same canonical declarations.
472        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        // Binding `status` collides with the FIELD `status`: the dot-guarded
503        // renamer must leave `t.status`-style field positions alone — here
504        // the field name inside the record block stays verbatim while the
505        // binding renames, so the two rules below stay canon-DIFFERENT.
506        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        // Rename + edit: the rename key must NOT match (fail-closed).
530        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}