fuzzy_parser/lib.rs
1//! Fuzzy JSON repair for LLM-generated DSL
2//!
3//! This crate provides generic fuzzy matching and automatic correction for
4//! JSON that may contain typos (common when generated by LLMs).
5//!
6//! # Design
7//!
8//! This crate provides **generic repair APIs** - the schema definitions
9//! live in the calling crate (e.g., your application defines the schema).
10//!
11//! The repair pipeline has three independent stages:
12//!
13//! 1. **Extract** ([`extract_json`]) — locate the JSON payload inside raw
14//! LLM output (Markdown code fences, surrounding prose, multiple blocks)
15//! 2. **Sanitize** ([`sanitize_json`]) — fix syntax errors (trailing
16//! commas, missing/mismatched closing delimiters, unclosed strings)
17//! 3. **Repair** ([`repair_tagged_enum_json`]) — fix typos and coerce
18//! value types, guided by a caller-provided schema
19//!
20//! # Features
21//!
22//! - **JSON extraction**: Pull JSON out of code fences and prose
23//! - **JSON sanitization**: Fix syntax errors (trailing commas, missing braces)
24//! - **Tagged enum repair**: Fix type discriminator typos (e.g., `"AddDeriv"` → `"AddDerive"`)
25//! - **Field name repair**: Fix field name typos (e.g., `"taget"` → `"target"`)
26//! - **Enum value repair**: Fix string values constrained to closed sets
27//! (e.g., `["Debg"]` → `["Debug"]`)
28//! - **Recursive schemas**: Nested objects and arrays of objects are
29//! repaired to any depth ([`FieldKind`])
30//! - **Type coercion**: Fix string-encoded scalars (e.g., `"42"` → `42`)
31//! - **Dynamic schemas**: Schemas own their data and can be built at runtime
32//! - **JSON Schema import**: Derive repair schemas from JSON Schema
33//! documents ([`TaggedEnumSchema::from_json_schema`]) or, with the
34//! `schemars` feature, directly from `#[derive(JsonSchema)]` types
35//! - **Transparency**: Every applied change is a [`Correction`]; every
36//! collision-skipped rename is a [`SkippedCorrection`]
37//! - **Multiple algorithm support**: Jaro-Winkler, Levenshtein, Damerau-Levenshtein
38//! - **Configurable similarity threshold**
39//!
40//! # Example
41//!
42//! ````
43//! use fuzzy_parser::{
44//! extract_json, sanitize_json, repair_tagged_enum_json, TaggedEnumSchema, FuzzyOptions,
45//! };
46//!
47//! // Define schema with enum arrays and nested objects
48//! let schema = TaggedEnumSchema::new(
49//! "type", // tag field
50//! &["AddDerive", "RemoveDerive"], // valid types
51//! |tag| match tag {
52//! "AddDerive" | "RemoveDerive" => Some(&["target", "derives", "config"][..]),
53//! _ => None,
54//! },
55//! )
56//! .with_enum_array("derives", &["Debug", "Clone", "Serialize", "Default"])
57//! .with_nested_object("config", &["timeout", "retries"]);
58//!
59//! // Raw LLM output: prose + code fence + syntax errors + typos
60//! let raw = r#"Here is the change you asked for:
61//!
62//! ```json
63//! {"type": "AddDeriv", "taget": "User", "derives": ["Debg",], "config": {"timout": 30,}}
64//! ```
65//! "#;
66//!
67//! // Step 0: Extract (strip prose and code fences)
68//! let payload = extract_json(raw).unwrap();
69//!
70//! // Step 1: Sanitize (fix syntax errors)
71//! let sanitized = sanitize_json(payload);
72//!
73//! // Step 2: Repair (fix typos)
74//! let result = repair_tagged_enum_json(&sanitized, &schema, &FuzzyOptions::default()).unwrap();
75//!
76//! assert_eq!(result.repaired["type"], "AddDerive");
77//! assert!(result.repaired.get("target").is_some());
78//! assert_eq!(result.repaired["derives"][0], "Debug");
79//! assert!(result.repaired["config"].get("timeout").is_some());
80//! ````
81
82#![warn(missing_docs)]
83
84pub mod distance;
85pub mod error;
86pub mod extract;
87pub mod import;
88pub mod repair;
89pub mod sanitize;
90pub mod schema;
91
92// Re-export main types
93pub use distance::{Algorithm, Match};
94pub use error::FuzzyError;
95pub use extract::{extract_json, extract_json_blocks, strip_code_fences};
96pub use import::{ImportWarning, SchemaImport};
97pub use repair::{
98 repair_enum_array, repair_fields_with_list, repair_object_fields, repair_tagged_enum,
99 repair_tagged_enum_array, repair_tagged_enum_json, Correction, FuzzyOptions, RepairLog,
100 RepairResult, SkipReason, SkippedCorrection,
101};
102pub use sanitize::sanitize_json;
103pub use schema::{FieldDef, FieldKind, ObjectSchema, TaggedEnumSchema};
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108
109 #[test]
110 fn test_full_workflow() {
111 // Define a simple schema
112 let schema =
113 TaggedEnumSchema::new("type", &["AddDerive", "RenameIdent"], |tag| match tag {
114 "AddDerive" => Some(&["target", "derives"][..]),
115 "RenameIdent" => Some(&["from", "to", "kind"][..]),
116 _ => None,
117 });
118
119 // Simulate LLM output with typos
120 let llm_output = r#"{
121 "type": "AddDeriv",
122 "taget": "DatabaseConfig",
123 "derives": ["Debug", "Clone"]
124 }"#;
125
126 let result =
127 repair_tagged_enum_json(llm_output, &schema, &FuzzyOptions::default()).unwrap();
128
129 // Verify corrections
130 assert_eq!(result.repaired["type"], "AddDerive");
131 assert_eq!(result.repaired["target"], "DatabaseConfig");
132 assert_eq!(result.corrections.len(), 2);
133 }
134
135 #[test]
136 fn test_sanitize_then_repair_workflow() {
137 // Define schema with enum arrays
138 let schema =
139 TaggedEnumSchema::new("type", &["AddDerive"], |_| Some(&["target", "derives"][..]))
140 .with_enum_array("derives", ["Debug", "Clone", "Serialize"]);
141
142 // LLM output with BOTH syntax errors AND typos
143 let malformed_llm_output = r#"{
144 "type": "AddDeriv",
145 "taget": "User",
146 "derives": ["Debg", "Clne",],
147 }"#;
148
149 // Step 1: Sanitize
150 let sanitized = sanitize_json(malformed_llm_output);
151
152 // Verify sanitization worked
153 assert!(!sanitized.contains(",]"));
154 assert!(!sanitized.contains(",}"));
155
156 // Step 2: Repair
157 let result =
158 repair_tagged_enum_json(&sanitized, &schema, &FuzzyOptions::default()).unwrap();
159
160 // Verify all corrections
161 assert_eq!(result.repaired["type"], "AddDerive");
162 assert!(result.repaired.get("target").is_some());
163 assert_eq!(result.repaired["derives"][0], "Debug");
164 assert_eq!(result.repaired["derives"][1], "Clone");
165 }
166
167 #[test]
168 fn test_sanitize_then_repair_missing_brace() {
169 let schema = TaggedEnumSchema::new("type", &["Action"], |_| Some(&["name"][..]));
170
171 // LLM truncated output
172 let truncated = r#"{"type": "Action", "name": "test"#;
173
174 let sanitized = sanitize_json(truncated);
175 let result =
176 repair_tagged_enum_json(&sanitized, &schema, &FuzzyOptions::default()).unwrap();
177
178 assert_eq!(result.repaired["type"], "Action");
179 assert_eq!(result.repaired["name"], "test");
180 }
181
182 #[test]
183 fn test_extract_sanitize_repair_full_pipeline() {
184 // Raw LLM output: prose + fence + trailing comma + typos + truncation
185 let raw = "Sure, here it is:\n\n```json\n{\"type\": \"AddDeriv\", \"taget\": \"User\", \"derives\": [\"Debg\",]\n```";
186
187 let schema =
188 TaggedEnumSchema::new("type", &["AddDerive"], |_| Some(&["target", "derives"][..]))
189 .with_enum_array("derives", ["Debug", "Clone"]);
190
191 let payload = extract_json(raw).unwrap();
192 let sanitized = sanitize_json(payload);
193 let result =
194 repair_tagged_enum_json(&sanitized, &schema, &FuzzyOptions::default()).unwrap();
195
196 assert_eq!(result.repaired["type"], "AddDerive");
197 assert_eq!(result.repaired["target"], "User");
198 assert_eq!(result.repaired["derives"][0], "Debug");
199 }
200}