1use formualizer_parse::parser::ParserError;
2use formualizer_parse::tokenizer::{
3 RecoveryAction, TokenDiagnostic, TokenStream, TokenSubType, TokenType,
4};
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8
9const MAX_GROUPS: usize = 50;
10const MAX_SAMPLE_ADDRESSES: usize = 5;
11const FORMULA_PREVIEW_MAX_BYTES: usize = 80;
12
13pub const FORMULA_PARSE_FAILED: &str = "FORMULA_PARSE_FAILED";
14pub const FORMULA_PARSE_FAILED_PREFIX: &str = "formula parse failed: ";
15
16#[derive(
17 Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, clap::ValueEnum, Default,
18)]
19#[serde(rename_all = "snake_case")]
20pub enum FormulaParsePolicy {
21 Fail,
23 #[default]
25 Warn,
26 Off,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
31#[serde(rename_all = "snake_case")]
32pub enum CommandClass {
33 SingleWrite,
34 BatchWrite,
35 ReadAnalysis,
36}
37
38impl FormulaParsePolicy {
39 pub fn default_for_command_class(class: CommandClass) -> Self {
40 match class {
41 CommandClass::SingleWrite => FormulaParsePolicy::Fail,
42 CommandClass::BatchWrite | CommandClass::ReadAnalysis => FormulaParsePolicy::Warn,
43 }
44 }
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
48pub struct FormulaParseErrorGroup {
49 pub error_code: String,
50 pub error_message: String,
51 pub sheet_name: String,
52 pub formula_preview: String,
53 pub count: usize,
54 #[serde(skip_serializing_if = "Vec::is_empty")]
55 pub sample_addresses: Vec<String>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
59pub struct FormulaParseDiagnostics {
60 pub policy: FormulaParsePolicy,
61 pub total_errors: usize,
62 pub groups_truncated: bool,
63 #[serde(skip_serializing_if = "Vec::is_empty")]
64 pub groups: Vec<FormulaParseErrorGroup>,
65}
66
67pub struct FormulaParseDiagnosticsBuilder {
68 policy: FormulaParsePolicy,
69 groups: BTreeMap<(String, String, String), GroupAccumulator>,
70 total_errors: usize,
71}
72
73struct GroupAccumulator {
74 error_code: String,
75 error_message: String,
76 formula_preview: String,
77 count: usize,
78 sample_addresses: Vec<String>,
79}
80
81impl FormulaParseDiagnosticsBuilder {
82 pub fn new(policy: FormulaParsePolicy) -> Self {
83 Self {
84 policy,
85 groups: BTreeMap::new(),
86 total_errors: 0,
87 }
88 }
89
90 pub fn record_error(&mut self, sheet: &str, address: &str, formula: &str, error: &str) {
91 let formula_preview = truncate_formula_preview(formula);
92 let normalized_formula = normalize_formula_for_grouping(formula);
93 let normalized_error = normalize_error_for_grouping(error);
94 let key = (sheet.to_string(), normalized_error, normalized_formula);
95
96 self.total_errors += 1;
97
98 let group = self.groups.entry(key).or_insert_with(|| GroupAccumulator {
99 error_code: FORMULA_PARSE_FAILED.to_string(),
100 error_message: error.to_string(),
101 formula_preview,
102 count: 0,
103 sample_addresses: Vec::new(),
104 });
105
106 group.count += 1;
107 if group.sample_addresses.len() < MAX_SAMPLE_ADDRESSES {
108 group.sample_addresses.push(address.to_string());
109 }
110 }
111
112 pub fn build(self) -> FormulaParseDiagnostics {
113 let groups_truncated = self.groups.len() > MAX_GROUPS;
114 let groups = self
115 .groups
116 .into_iter()
117 .take(MAX_GROUPS)
118 .map(
119 |((sheet_name, _error_message, _normalized_key), group)| FormulaParseErrorGroup {
120 error_code: group.error_code,
121 error_message: group.error_message,
122 sheet_name,
123 formula_preview: group.formula_preview,
124 count: group.count,
125 sample_addresses: group.sample_addresses,
126 },
127 )
128 .collect();
129
130 FormulaParseDiagnostics {
131 policy: self.policy,
132 total_errors: self.total_errors,
133 groups_truncated,
134 groups,
135 }
136 }
137
138 pub fn is_empty(&self) -> bool {
139 self.total_errors == 0
140 }
141
142 pub fn has_errors(&self) -> bool {
143 self.total_errors > 0
144 }
145}
146
147fn truncate_formula_preview(formula: &str) -> String {
148 if formula.len() <= FORMULA_PREVIEW_MAX_BYTES {
149 return formula.to_string();
150 }
151
152 let mut end = FORMULA_PREVIEW_MAX_BYTES;
153 while end > 0 && !formula.is_char_boundary(end) {
154 end -= 1;
155 }
156
157 let mut result = formula[..end].to_string();
158 result.push('…');
159 result
160}
161
162fn normalize_formula_for_grouping(formula: &str) -> String {
166 if let Ok(stream) = TokenStream::new(formula) {
167 let mut out = String::with_capacity(formula.len());
168 if formula.starts_with('=') {
169 out.push('=');
170 }
171 for span in &stream.spans {
172 if span.token_type == TokenType::Operand && span.subtype == TokenSubType::Range {
173 out.push_str("$REF");
174 } else if let Some(val) = stream.source().get(span.start..span.end) {
175 out.push_str(val);
176 }
177 }
178 return truncate_formula_preview(&out);
179 }
180
181 normalize_refs_regex(formula)
184}
185
186fn normalize_refs_regex(formula: &str) -> String {
189 let bytes = formula.as_bytes();
190 let mut out = String::with_capacity(formula.len());
191 let mut i = 0;
192
193 while i < bytes.len() {
194 let start = i;
196 if bytes[i] == b'$' && i + 1 < bytes.len() && bytes[i + 1].is_ascii_alphabetic() {
197 }
199
200 let mut j = i;
202 if j < bytes.len() && bytes[j] == b'$' {
204 j += 1;
205 }
206 let col_start = j;
208 while j < bytes.len() && bytes[j].is_ascii_alphabetic() && j - col_start < 4 {
209 j += 1;
210 }
211 let col_len = j - col_start;
212 if (1..=3).contains(&col_len) {
213 if j < bytes.len() && bytes[j] == b'$' {
215 j += 1;
216 }
217 let row_start = j;
219 while j < bytes.len() && bytes[j].is_ascii_digit() {
220 j += 1;
221 }
222 let row_len = j - row_start;
223 if row_len >= 1 {
224 let preceded_by_alpha = start > 0
226 && (bytes[start - 1].is_ascii_alphanumeric() || bytes[start - 1] == b'_');
227 let followed_by_alpha =
228 j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_');
229 if !preceded_by_alpha && !followed_by_alpha {
230 out.push_str("$REF");
231 i = j;
232 continue;
233 }
234 }
235 }
236
237 out.push(bytes[i] as char);
238 i += 1;
239 }
240
241 truncate_formula_preview(&out)
242}
243
244fn normalize_error_for_grouping(error: &str) -> String {
247 let mut out = String::with_capacity(error.len());
248 let bytes = error.as_bytes();
249 let mut i = 0;
250
251 while i < bytes.len() {
252 if i + 9 <= bytes.len() && &bytes[i..i + 9] == b"position " {
254 out.push_str("position ");
255 i += 9;
256 if i < bytes.len() && bytes[i].is_ascii_digit() {
257 out.push('N');
258 while i < bytes.len() && bytes[i].is_ascii_digit() {
259 i += 1;
260 }
261 continue;
262 }
263 }
264
265 if i + 6 <= bytes.len() && &bytes[i..i + 6] == b"bytes " {
267 let mut cursor = i + 6;
268 if cursor < bytes.len() && bytes[cursor].is_ascii_digit() {
269 out.push_str("bytes N");
270 while cursor < bytes.len() && bytes[cursor].is_ascii_digit() {
271 cursor += 1;
272 }
273 if cursor + 2 <= bytes.len() && &bytes[cursor..cursor + 2] == b".." {
274 cursor += 2;
275 out.push_str("..N");
276 while cursor < bytes.len() && bytes[cursor].is_ascii_digit() {
277 cursor += 1;
278 }
279 }
280 i = cursor;
281 continue;
282 }
283 }
284
285 out.push(bytes[i] as char);
286 i += 1;
287 }
288
289 out
290}
291
292#[derive(Debug, Clone, PartialEq, Eq)]
293pub struct FormulaParseFailure {
294 pub parser_message: String,
295 pub parser_position: Option<usize>,
296 pub tokenizer: Option<TokenizerRecovery>,
297}
298
299#[derive(Debug, Clone, PartialEq, Eq)]
300pub struct TokenizerRecovery {
301 pub message: String,
302 pub recovery: RecoveryAction,
303 pub span_start: usize,
304 pub span_end: usize,
305}
306
307impl FormulaParseFailure {
308 fn from_parser_error(formula: &str, err: &ParserError) -> Self {
309 let tokenizer = first_tokenizer_recovery(formula);
310 Self {
311 parser_message: err.message.clone(),
312 parser_position: err.position,
313 tokenizer,
314 }
315 }
316
317 fn render_message(&self) -> String {
318 let parser_message = match self.parser_position {
319 Some(pos) => format!("parse error at position {pos}: {}", self.parser_message),
320 None => format!("parse error: {}", self.parser_message),
321 };
322
323 if let Some(tokenizer) = &self.tokenizer {
324 format!(
325 "{parser_message} (tokenizer recovery {:?} at bytes {}..{}: {})",
326 tokenizer.recovery, tokenizer.span_start, tokenizer.span_end, tokenizer.message
327 )
328 } else {
329 parser_message
330 }
331 }
332}
333
334impl std::fmt::Display for FormulaParseFailure {
335 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
336 f.write_str(&self.render_message())
337 }
338}
339
340impl std::error::Error for FormulaParseFailure {}
341
342fn first_tokenizer_recovery(formula: &str) -> Option<TokenizerRecovery> {
343 let stream = TokenStream::new_best_effort(formula);
344 let diagnostic = stream.diagnostics_ref().first()?;
345 Some(tokenizer_recovery_from_diagnostic(diagnostic))
346}
347
348fn tokenizer_recovery_from_diagnostic(diagnostic: &TokenDiagnostic) -> TokenizerRecovery {
349 TokenizerRecovery {
350 message: diagnostic.message.clone(),
351 recovery: diagnostic.recovery,
352 span_start: diagnostic.span.start,
353 span_end: diagnostic.span.end,
354 }
355}
356
357fn normalize_formula_input(formula: &str) -> String {
358 let trimmed = formula.trim();
359 if trimmed.starts_with('=') {
360 trimmed.to_string()
361 } else {
362 format!("={trimmed}")
363 }
364}
365
366pub fn validate_formula_detailed(formula: &str) -> Result<(), FormulaParseFailure> {
367 let formula_in = normalize_formula_input(formula);
368 formualizer_parse::parse(&formula_in)
369 .map(|_| ())
370 .map_err(|err| FormulaParseFailure::from_parser_error(&formula_in, &err))
371}
372
373pub fn format_formula_parse_failure(formula: &str, err: &ParserError) -> String {
374 let formula_in = normalize_formula_input(formula);
375 FormulaParseFailure::from_parser_error(&formula_in, err).to_string()
376}
377
378pub fn validate_formula(formula: &str) -> Result<(), String> {
381 validate_formula_detailed(formula).map_err(|err| err.to_string())
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387
388 #[test]
389 fn test_policy_default_is_warn() {
390 assert_eq!(FormulaParsePolicy::default(), FormulaParsePolicy::Warn);
391 }
392
393 #[test]
394 fn test_policy_default_for_single_write() {
395 assert_eq!(
396 FormulaParsePolicy::default_for_command_class(CommandClass::SingleWrite),
397 FormulaParsePolicy::Fail
398 );
399 }
400
401 #[test]
402 fn test_policy_default_for_batch_write() {
403 assert_eq!(
404 FormulaParsePolicy::default_for_command_class(CommandClass::BatchWrite),
405 FormulaParsePolicy::Warn
406 );
407 }
408
409 #[test]
410 fn test_policy_default_for_read_analysis() {
411 assert_eq!(
412 FormulaParsePolicy::default_for_command_class(CommandClass::ReadAnalysis),
413 FormulaParsePolicy::Warn
414 );
415 }
416
417 #[test]
418 fn test_policy_serde_roundtrip() {
419 let cases = [
420 (FormulaParsePolicy::Fail, "fail"),
421 (FormulaParsePolicy::Warn, "warn"),
422 (FormulaParsePolicy::Off, "off"),
423 ];
424
425 for (policy, expected) in cases {
426 let serialized = serde_json::to_string(&policy).expect("serialize policy");
427 assert_eq!(serialized, format!("\"{expected}\""));
428
429 let deserialized: FormulaParsePolicy =
430 serde_json::from_str(&serialized).expect("deserialize policy");
431 assert_eq!(deserialized, policy);
432 }
433 }
434
435 #[test]
436 fn test_empty_builder() {
437 let builder = FormulaParseDiagnosticsBuilder::new(FormulaParsePolicy::Warn);
438 assert!(builder.is_empty());
439 assert!(!builder.has_errors());
440
441 let diagnostics = builder.build();
442 assert_eq!(diagnostics.policy, FormulaParsePolicy::Warn);
443 assert_eq!(diagnostics.total_errors, 0);
444 assert!(!diagnostics.groups_truncated);
445 assert!(diagnostics.groups.is_empty());
446 }
447
448 #[test]
449 fn test_single_error_group() {
450 let mut builder = FormulaParseDiagnosticsBuilder::new(FormulaParsePolicy::Warn);
451 builder.record_error("Sheet1", "A1", "=SUM(A:A)", "unexpected token");
452
453 let diagnostics = builder.build();
454 assert_eq!(diagnostics.total_errors, 1);
455 assert_eq!(diagnostics.groups.len(), 1);
456 let group = &diagnostics.groups[0];
457 assert_eq!(group.count, 1);
458 assert_eq!(group.error_code, FORMULA_PARSE_FAILED);
459 }
460
461 #[test]
462 fn test_grouping_same_key() {
463 let mut builder = FormulaParseDiagnosticsBuilder::new(FormulaParsePolicy::Warn);
464 builder.record_error("Sheet1", "A1", "=SUM(A:A)", "unexpected token");
465 builder.record_error("Sheet1", "A2", "=SUM(A:A)", "unexpected token");
466 builder.record_error("Sheet1", "A3", "=SUM(A:A)", "unexpected token");
467
468 let diagnostics = builder.build();
469 assert_eq!(diagnostics.groups.len(), 1);
470 let group = &diagnostics.groups[0];
471 assert_eq!(group.count, 3);
472 assert_eq!(group.sample_addresses, vec!["A1", "A2", "A3"]);
473 }
474
475 #[test]
476 fn test_grouping_different_sheets() {
477 let mut builder = FormulaParseDiagnosticsBuilder::new(FormulaParsePolicy::Warn);
478 builder.record_error("A", "A1", "=SUM(A:A)", "unexpected token");
479 builder.record_error("B", "A1", "=SUM(A:A)", "unexpected token");
480
481 let diagnostics = builder.build();
482 assert_eq!(diagnostics.groups.len(), 2);
483 assert_eq!(diagnostics.groups[0].sheet_name, "A");
484 assert_eq!(diagnostics.groups[1].sheet_name, "B");
485 }
486
487 #[test]
488 fn test_grouping_different_messages() {
489 let mut builder = FormulaParseDiagnosticsBuilder::new(FormulaParsePolicy::Warn);
490 builder.record_error("Sheet1", "A1", "=SUM(A:A)", "unexpected token");
491 builder.record_error("Sheet1", "B1", "=SUM(A:A)", "unknown function");
492
493 let diagnostics = builder.build();
494 assert_eq!(diagnostics.groups.len(), 2);
495 }
496
497 #[test]
498 fn test_sample_address_cap_at_5() {
499 let mut builder = FormulaParseDiagnosticsBuilder::new(FormulaParsePolicy::Warn);
500
501 for i in 1..=8 {
502 builder.record_error("Sheet1", &format!("A{i}"), "=SUM(A:A)", "unexpected token");
503 }
504
505 let diagnostics = builder.build();
506 let group = &diagnostics.groups[0];
507 assert_eq!(group.count, 8);
508 assert_eq!(group.sample_addresses.len(), 5);
509 assert_eq!(group.sample_addresses, vec!["A1", "A2", "A3", "A4", "A5"]);
510 }
511
512 #[test]
513 fn test_deterministic_ordering() {
514 let mut builder = FormulaParseDiagnosticsBuilder::new(FormulaParsePolicy::Warn);
515 builder.record_error("C", "A1", "=1", "err");
516 builder.record_error("A", "A1", "=1", "err");
517 builder.record_error("B", "A1", "=1", "err");
518
519 let diagnostics = builder.build();
520 let sheets: Vec<&str> = diagnostics
521 .groups
522 .iter()
523 .map(|group| group.sheet_name.as_str())
524 .collect();
525 assert_eq!(sheets, vec!["A", "B", "C"]);
526 }
527
528 #[test]
529 fn test_groups_truncated_at_50() {
530 let mut builder = FormulaParseDiagnosticsBuilder::new(FormulaParsePolicy::Warn);
531
532 for i in 0..60 {
535 builder.record_error(
536 "Sheet1",
537 "A1",
538 &format!("=FUNC{i}(A1)"),
539 &format!("error variant {i}"),
540 );
541 }
542
543 let diagnostics = builder.build();
544 assert_eq!(diagnostics.total_errors, 60);
545 assert_eq!(diagnostics.groups.len(), 50);
546 assert!(diagnostics.groups_truncated);
547 }
548
549 #[test]
550 fn test_formula_preview_truncation() {
551 let mut builder = FormulaParseDiagnosticsBuilder::new(FormulaParsePolicy::Warn);
552 let formula = format!("={}", "A".repeat(119));
553 assert_eq!(formula.len(), 120);
554
555 builder.record_error("Sheet1", "A1", &formula, "unexpected token");
556 let diagnostics = builder.build();
557
558 let preview = &diagnostics.groups[0].formula_preview;
559 assert!(preview.ends_with('…'));
560 assert_ne!(preview, &formula);
561 assert!(preview.len() <= FORMULA_PREVIEW_MAX_BYTES + '…'.len_utf8());
562 }
563
564 #[test]
565 fn test_diagnostics_json_structure() {
566 let mut builder = FormulaParseDiagnosticsBuilder::new(FormulaParsePolicy::Warn);
567 builder.record_error("Sheet1", "A1", "=SUM(A:A)", "unexpected token");
568 let diagnostics = builder.build();
569
570 let value = serde_json::to_value(diagnostics).expect("serialize diagnostics");
571 assert_eq!(value["policy"], serde_json::json!("warn"));
572 assert_eq!(value["total_errors"], serde_json::json!(1));
573 assert_eq!(value["groups_truncated"], serde_json::json!(false));
574 assert!(value["groups"].is_array());
575
576 let group = &value["groups"][0];
577 assert_eq!(group["error_code"], serde_json::json!(FORMULA_PARSE_FAILED));
578 assert_eq!(
579 group["error_message"],
580 serde_json::json!("unexpected token")
581 );
582 assert_eq!(group["sheet_name"], serde_json::json!("Sheet1"));
583 assert_eq!(group["formula_preview"], serde_json::json!("=SUM(A:A)"));
584 assert_eq!(group["count"], serde_json::json!(1));
585 assert!(group["sample_addresses"].is_array());
586 }
587
588 #[test]
589 fn test_has_errors_after_record() {
590 let mut builder = FormulaParseDiagnosticsBuilder::new(FormulaParsePolicy::Warn);
591 builder.record_error("Sheet1", "A1", "=SUM(A:A)", "unexpected token");
592
593 assert!(builder.has_errors());
594 assert!(!builder.is_empty());
595 }
596
597 #[test]
598 fn test_validate_formula_valid() {
599 assert!(validate_formula("SUM(A1:A10)").is_ok());
600 assert!(validate_formula("=SUM(A1:A10)").is_ok());
601 assert!(validate_formula("A1+B1").is_ok());
602 assert!(validate_formula("IF(A1>0,1,0)").is_ok());
603 }
604
605 #[test]
606 fn test_validate_formula_invalid() {
607 assert!(validate_formula("SUM(A1:A10").is_err()); assert!(validate_formula("SUM(A1:A10))").is_err()); }
610
611 #[test]
612 fn test_validate_formula_detailed_includes_recovery_context() {
613 let err = validate_formula_detailed("SUM(A1:A10")
614 .expect_err("unterminated formula should return parse diagnostics");
615 let rendered = err.to_string();
616 assert!(rendered.contains("parse error"));
617 assert!(rendered.contains("tokenizer recovery"));
618 assert!(rendered.contains("bytes "));
619 }
620
621 #[test]
622 fn test_normalize_error_for_grouping_normalizes_bytes_ranges() {
623 let n1 = normalize_error_for_grouping(
624 "parse error at position 14 (tokenizer recovery UnmatchedOpener at bytes 1..9: x)",
625 );
626 let n2 = normalize_error_for_grouping(
627 "parse error at position 29 (tokenizer recovery UnmatchedOpener at bytes 7..15: x)",
628 );
629 assert_eq!(n1, n2);
630 }
631
632 #[test]
633 fn test_grouping_normalizes_cell_references() {
634 let mut builder = FormulaParseDiagnosticsBuilder::new(FormulaParsePolicy::Warn);
637 builder.record_error(
638 "Assessments",
639 "D4",
640 "=IF(C4=\"\",\"\",IF(C4=\"N/A\",\"\",0))",
641 "parse error at position 42",
642 );
643 builder.record_error(
644 "Assessments",
645 "D5",
646 "=IF(C5=\"\",\"\",IF(C5=\"N/A\",\"\",0))",
647 "parse error at position 42",
648 );
649 builder.record_error(
650 "Assessments",
651 "D10",
652 "=IF(C10=\"\",\"\",IF(C10=\"N/A\",\"\",0))",
653 "parse error at position 42",
654 );
655
656 let diagnostics = builder.build();
657 assert_eq!(diagnostics.total_errors, 3);
659 assert_eq!(diagnostics.groups.len(), 1);
660
661 let group = &diagnostics.groups[0];
662 assert_eq!(group.count, 3);
663 assert_eq!(group.sample_addresses, vec!["D4", "D5", "D10"]);
664 assert!(group.formula_preview.contains("C4"));
666 }
667
668 #[test]
669 fn test_grouping_different_structure_not_collapsed() {
670 let mut builder = FormulaParseDiagnosticsBuilder::new(FormulaParsePolicy::Warn);
672 builder.record_error("Sheet1", "A1", "=SUM(A1:A10)", "unexpected token");
673 builder.record_error("Sheet1", "A2", "=AVERAGE(B1:B10)", "unexpected token");
674
675 let diagnostics = builder.build();
676 assert_eq!(diagnostics.groups.len(), 2);
677 }
678
679 #[test]
680 fn test_normalize_formula_for_grouping() {
681 let n1 = normalize_formula_for_grouping("=IF(C4=\"\",\"\",0)");
683 let n2 = normalize_formula_for_grouping("=IF(C5=\"\",\"\",0)");
684 let n3 = normalize_formula_for_grouping("=IF(C100=\"\",\"\",0)");
685 assert_eq!(n1, n2);
686 assert_eq!(n2, n3);
687
688 let n4 = normalize_formula_for_grouping("=SUM(A1:A10)");
690 let n5 = normalize_formula_for_grouping("=AVERAGE(A1:A10)");
691 assert_ne!(n4, n5);
692
693 let bad = normalize_formula_for_grouping("=SUM(A1:A10))");
695 assert!(!bad.is_empty());
696 }
697}