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