1use crate::ast::VarDoc;
7use crate::hash::hash_source;
8use crate::plan::{ExecutionPlan, derive_example_name};
9use crate::span::Span;
10use crate::value::Value;
11use regex::Regex;
12use std::collections::{BTreeMap, HashSet};
13use std::sync::LazyLock;
14
15pub const SIMILARITY_THRESHOLD: f64 = 0.5;
17
18#[derive(Clone, Debug, PartialEq, Eq)]
20pub struct BaselineExample {
21 pub name: String,
22 pub line: usize,
23}
24
25#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct SpecBaseline {
28 pub source_hash: String,
29 pub examples: Vec<BaselineExample>,
30}
31
32#[derive(Clone, Debug, PartialEq, Eq)]
34pub struct VarLock {
35 pub version: u32,
36 pub specs: BTreeMap<String, SpecBaseline>,
37}
38
39#[derive(Clone, Debug, PartialEq, Eq)]
41pub struct Drifted {
42 pub name: String,
43 pub line: usize,
44 pub span: Span,
45}
46
47pub trait BaselineStore {
50 fn read(&self) -> Option<String>;
52
53 fn write(&mut self, contents: &str);
54}
55
56static TOKEN_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[\p{L}\p{N}]+").unwrap());
57
58fn within(inner: Span, outer: Span) -> bool {
59 inner.start_offset >= outer.start_offset && inner.end_offset <= outer.end_offset
60}
61
62fn is_live(candidate_span: Span, plan: &ExecutionPlan) -> bool {
63 plan.examples
64 .iter()
65 .any(|pe| within(pe.span, candidate_span))
66}
67
68fn tokenize(text: &str) -> HashSet<String> {
69 TOKEN_RE
70 .find_iter(&text.to_lowercase())
71 .map(|m| m.as_str().to_string())
72 .collect()
73}
74
75fn similarity(a: &HashSet<String>, b: &HashSet<String>) -> f64 {
76 if a.is_empty() && b.is_empty() {
77 return 1.0;
78 }
79 let intersection = a.iter().filter(|t| b.contains(*t)).count();
80 let union = a.len() + b.len() - intersection;
81 if union == 0 {
82 0.0
83 } else {
84 intersection as f64 / union as f64
85 }
86}
87
88pub fn live_examples(var_doc: &VarDoc, plan: &ExecutionPlan) -> Vec<BaselineExample> {
90 var_doc
91 .examples
92 .iter()
93 .filter(|c| is_live(c.span, plan))
94 .map(|c| BaselineExample {
95 name: derive_example_name(&c.body),
96 line: c.span.start_line,
97 })
98 .collect()
99}
100
101pub fn derive_spec_baseline(source: &str, var_doc: &VarDoc, plan: &ExecutionPlan) -> SpecBaseline {
103 SpecBaseline {
104 source_hash: hash_source(source),
105 examples: live_examples(var_doc, plan),
106 }
107}
108
109pub fn detect_drift(
111 baseline: Option<&SpecBaseline>,
112 var_doc: &VarDoc,
113 plan: &ExecutionPlan,
114) -> Vec<Drifted> {
115 let Some(baseline) = baseline else {
116 return Vec::new();
117 };
118 let candidates = &var_doc.examples;
119 let n = candidates.len();
120 let tokens: Vec<HashSet<String>> = candidates
121 .iter()
122 .map(|c| tokenize(&derive_example_name(&c.body)))
123 .collect();
124 let live: Vec<bool> = candidates.iter().map(|c| is_live(c.span, plan)).collect();
125
126 let mut drifts = Vec::new();
127 for b in &baseline.examples {
128 let b_tokens = tokenize(&b.name);
129 let mut best_idx: Option<usize> = None;
130 let mut best_score = 0.0f64;
131 for i in 0..n {
132 let score = similarity(&b_tokens, &tokens[i]);
133 if score < SIMILARITY_THRESHOLD {
134 continue;
135 }
136 let line = candidates[i].span.start_line as isize;
137 let best_line = best_idx.map_or(0, |bi| candidates[bi].span.start_line as isize);
138 let b_line = b.line as isize;
139 if best_idx.is_none()
140 || score > best_score
141 || (score == best_score && (line - b_line).abs() < (best_line - b_line).abs())
142 {
143 best_idx = Some(i);
144 best_score = score;
145 }
146 }
147 if let Some(bi) = best_idx {
148 if !live[bi] {
149 let cand = &candidates[bi];
150 drifts.push(Drifted {
151 name: b.name.clone(),
152 line: cand.span.start_line,
153 span: cand.span,
154 });
155 }
156 }
157 }
158 drifts
159}
160
161pub fn message(drifted: &Drifted) -> String {
163 format!(
164 "This paragraph was an example and no longer matches any step (drift): \"{}\".\nFix the step so it matches again, or accept it as prose (run in update mode).",
165 drifted.name
166 )
167}
168
169pub fn reconcile_drift(
173 store: &mut dyn BaselineStore,
174 spec_path: &str,
175 source: &str,
176 var_doc: &VarDoc,
177 plan: &ExecutionPlan,
178 update: bool,
179) -> Vec<Drifted> {
180 let lock = store.read().as_deref().and_then(parse_var_lock);
181 let drifts = if update {
182 Vec::new()
183 } else {
184 detect_drift(lock.as_ref().and_then(|l| l.specs.get(spec_path)), var_doc, plan)
185 };
186 if update || drifts.is_empty() {
187 let next = derive_spec_baseline(source, var_doc, plan);
188 let mut specs = lock.map_or_else(BTreeMap::new, |l| l.specs);
189 specs.insert(spec_path.to_string(), next);
190 store.write(&stringify_var_lock(&VarLock { version: 1, specs }));
191 }
192 drifts
193}
194
195pub fn stringify_var_lock(lock: &VarLock) -> String {
198 let mut sb = String::new();
199 sb.push_str("{\n \"version\": 1,\n \"specs\": ");
200 if lock.specs.is_empty() {
201 sb.push_str("{}");
202 } else {
203 sb.push_str("{\n");
204 let n = lock.specs.len();
205 for (p, (path, baseline)) in lock.specs.iter().enumerate() {
207 sb.push_str(" ");
208 write_json_string(&mut sb, path);
209 sb.push_str(": {\n \"sourceHash\": ");
210 write_json_string(&mut sb, &baseline.source_hash);
211 sb.push_str(",\n \"examples\": ");
212 if baseline.examples.is_empty() {
213 sb.push_str("[]");
214 } else {
215 sb.push_str("[\n");
216 let en = baseline.examples.len();
217 for (e, ex) in baseline.examples.iter().enumerate() {
218 sb.push_str(" {\n \"name\": ");
219 write_json_string(&mut sb, &ex.name);
220 sb.push_str(",\n \"line\": ");
221 sb.push_str(&ex.line.to_string());
222 sb.push_str("\n }");
223 if e + 1 < en {
224 sb.push(',');
225 }
226 sb.push('\n');
227 }
228 sb.push_str(" ]");
229 }
230 sb.push_str("\n }");
231 if p + 1 < n {
232 sb.push(',');
233 }
234 sb.push('\n');
235 }
236 sb.push_str(" }");
237 }
238 sb.push_str("\n}\n");
239 sb
240}
241
242fn write_json_string(sb: &mut String, s: &str) {
243 use std::fmt::Write;
244 sb.push('"');
245 for c in s.chars() {
246 match c {
247 '"' => sb.push_str("\\\""),
248 '\\' => sb.push_str("\\\\"),
249 '\n' => sb.push_str("\\n"),
250 '\r' => sb.push_str("\\r"),
251 '\t' => sb.push_str("\\t"),
252 '\u{0008}' => sb.push_str("\\b"),
253 '\u{000c}' => sb.push_str("\\f"),
254 c if (c as u32) < 0x20 => {
255 let _ = write!(sb, "\\u{:04x}", c as u32);
256 }
257 c => sb.push(c),
258 }
259 }
260 sb.push('"');
261}
262
263pub fn parse_var_lock(text: &str) -> Option<VarLock> {
265 let parsed = JsonReader::new(text).parse_whole()?;
266 let Value::Map(obj) = parsed else { return None };
267 if !matches!(obj.get("version"), Some(Value::Int(1))) {
268 return None;
269 }
270 let Some(Value::Map(specs_raw)) = obj.get("specs") else {
271 return None;
272 };
273 let mut specs = BTreeMap::new();
274 for (k, v) in specs_raw {
275 specs.insert(k.clone(), parse_spec_baseline(v)?);
276 }
277 Some(VarLock { version: 1, specs })
278}
279
280fn parse_spec_baseline(value: &Value) -> Option<SpecBaseline> {
281 let Value::Map(map) = value else { return None };
282 let Some(Value::String(source_hash)) = map.get("sourceHash") else {
283 return None;
284 };
285 let Some(Value::List(examples_raw)) = map.get("examples") else {
286 return None;
287 };
288 let mut examples = Vec::new();
289 for item in examples_raw {
290 let Value::Map(e) = item else { return None };
291 let Some(Value::String(name)) = e.get("name") else {
292 return None;
293 };
294 let Some(Value::Int(line)) = e.get("line") else {
295 return None;
296 };
297 examples.push(BaselineExample {
298 name: name.clone(),
299 line: *line as usize,
300 });
301 }
302 Some(SpecBaseline {
303 source_hash: source_hash.clone(),
304 examples,
305 })
306}
307
308struct JsonReader {
311 chars: Vec<char>,
312 i: usize,
313}
314
315impl JsonReader {
316 fn new(text: &str) -> JsonReader {
317 JsonReader {
318 chars: text.chars().collect(),
319 i: 0,
320 }
321 }
322
323 fn parse_whole(&mut self) -> Option<Value> {
324 let v = self.value()?;
325 self.skip_ws();
326 if self.i != self.chars.len() {
327 return None;
328 }
329 Some(v)
330 }
331
332 fn value(&mut self) -> Option<Value> {
333 self.skip_ws();
334 match self.peek()? {
335 '{' => self.object(),
336 '[' => self.array(),
337 '"' => self.string().map(Value::String),
338 't' | 'f' => self.boolean(),
339 'n' => self.null(),
340 _ => self.number(),
341 }
342 }
343
344 fn object(&mut self) -> Option<Value> {
345 self.expect('{')?;
346 let mut map = BTreeMap::new();
347 self.skip_ws();
348 if self.peek()? == '}' {
349 self.i += 1;
350 return Some(Value::Map(map));
351 }
352 loop {
353 self.skip_ws();
354 let key = self.string()?;
355 self.skip_ws();
356 self.expect(':')?;
357 map.insert(key, self.value()?);
358 self.skip_ws();
359 match self.next()? {
360 '}' => return Some(Value::Map(map)),
361 ',' => {}
362 _ => return None,
363 }
364 }
365 }
366
367 fn array(&mut self) -> Option<Value> {
368 self.expect('[')?;
369 let mut list = Vec::new();
370 self.skip_ws();
371 if self.peek()? == ']' {
372 self.i += 1;
373 return Some(Value::List(list));
374 }
375 loop {
376 list.push(self.value()?);
377 self.skip_ws();
378 match self.next()? {
379 ']' => return Some(Value::List(list)),
380 ',' => {}
381 _ => return None,
382 }
383 }
384 }
385
386 fn string(&mut self) -> Option<String> {
387 self.expect('"')?;
388 let mut out = String::new();
389 loop {
390 match self.next()? {
391 '"' => return Some(out),
392 '\\' => match self.next()? {
393 '"' => out.push('"'),
394 '\\' => out.push('\\'),
395 '/' => out.push('/'),
396 'n' => out.push('\n'),
397 'r' => out.push('\r'),
398 't' => out.push('\t'),
399 'b' => out.push('\u{0008}'),
400 'f' => out.push('\u{000c}'),
401 'u' => {
402 let code = self.hex4()?;
403 out.push(char::from_u32(code)?);
404 }
405 _ => return None,
406 },
407 c => out.push(c),
408 }
409 }
410 }
411
412 fn hex4(&mut self) -> Option<u32> {
413 if self.i + 4 > self.chars.len() {
414 return None;
415 }
416 let slice: String = self.chars[self.i..self.i + 4].iter().collect();
417 self.i += 4;
418 u32::from_str_radix(&slice, 16).ok()
419 }
420
421 fn number(&mut self) -> Option<Value> {
422 let start = self.i;
423 while self.i < self.chars.len() && "-+.eE0123456789".contains(self.chars[self.i]) {
424 self.i += 1;
425 }
426 if self.i == start {
427 return None;
428 }
429 let num: String = self.chars[start..self.i].iter().collect();
430 if num.contains(['.', 'e', 'E']) {
431 num.parse::<f64>().ok().map(Value::Float)
432 } else {
433 num.parse::<i64>().ok().map(Value::Int)
434 }
435 }
436
437 fn boolean(&mut self) -> Option<Value> {
438 if self.starts_with("true") {
439 self.i += 4;
440 Some(Value::Bool(true))
441 } else if self.starts_with("false") {
442 self.i += 5;
443 Some(Value::Bool(false))
444 } else {
445 None
446 }
447 }
448
449 fn null(&mut self) -> Option<Value> {
450 if self.starts_with("null") {
451 self.i += 4;
452 Some(Value::Null)
453 } else {
454 None
455 }
456 }
457
458 fn starts_with(&self, lit: &str) -> bool {
459 let lit: Vec<char> = lit.chars().collect();
460 self.i + lit.len() <= self.chars.len() && self.chars[self.i..self.i + lit.len()] == lit[..]
461 }
462
463 fn skip_ws(&mut self) {
464 while self.i < self.chars.len() && matches!(self.chars[self.i], ' ' | '\n' | '\r' | '\t') {
465 self.i += 1;
466 }
467 }
468
469 fn peek(&self) -> Option<char> {
470 self.chars.get(self.i).copied()
471 }
472
473 fn next(&mut self) -> Option<char> {
474 let c = self.chars.get(self.i).copied()?;
475 self.i += 1;
476 Some(c)
477 }
478
479 fn expect(&mut self, c: char) -> Option<()> {
480 if self.next()? == c { Some(()) } else { None }
481 }
482}