1use serde_json::Value as JsonValue;
8
9const MAX_SUGGESTION_DISTANCE: usize = 3;
11
12pub const AVAILABLE_FILTERS: &[&str] = &[
14 "toyaml",
16 "tojson",
17 "tojson_pretty",
18 "b64encode",
19 "b64decode",
20 "quote",
21 "squote",
22 "nindent",
23 "indent",
24 "required",
25 "empty",
26 "haskey",
27 "keys",
28 "merge",
29 "sha256",
30 "trunc",
31 "trimprefix",
32 "trimsuffix",
33 "snakecase",
34 "kebabcase",
35 "tostrings", "default",
38 "upper",
39 "lower",
40 "title",
41 "capitalize",
42 "replace",
43 "trim",
44 "join",
45 "first",
46 "last",
47 "length",
48 "reverse",
49 "sort",
50 "unique",
51 "map",
52 "select",
53 "reject",
54 "selectattr",
55 "rejectattr",
56 "batch",
57 "slice",
58 "dictsort",
59 "items",
60 "attr",
61 "int",
62 "float",
63 "abs",
64 "round",
65 "string",
66 "list",
67 "bool",
68 "safe",
69 "escape",
70 "e",
71 "urlencode",
72];
73
74pub const AVAILABLE_FUNCTIONS: &[&str] = &[
76 "fail",
78 "dict",
79 "list",
80 "get",
81 "coalesce",
82 "ternary",
83 "uuidv4",
84 "tostring",
85 "toint",
86 "tofloat",
87 "now",
88 "printf",
89 "tpl", "tpl_ctx", "lookup", "range",
94 "lipsum",
95 "cycler",
96 "joiner",
97 "namespace",
98];
99
100pub const CONTEXT_VARIABLES: &[&str] = &["values", "release", "pack", "capabilities", "template"];
102
103#[derive(Debug, Clone)]
105pub struct Suggestion {
106 pub text: String,
108 pub distance: usize,
110 pub category: SuggestionCategory,
112}
113
114#[derive(Debug, Clone, Copy, PartialEq)]
115pub enum SuggestionCategory {
116 Variable,
117 Filter,
118 Function,
119 Property,
120}
121
122pub fn levenshtein(a: &str, b: &str) -> usize {
124 strsim::levenshtein(a, b)
125}
126
127pub fn find_closest_matches(
129 input: &str,
130 candidates: &[&str],
131 max_results: usize,
132 category: SuggestionCategory,
133) -> Vec<Suggestion> {
134 let mut suggestions: Vec<Suggestion> = candidates
135 .iter()
136 .filter_map(|&candidate| {
137 let distance = levenshtein(input, candidate);
138 if distance <= MAX_SUGGESTION_DISTANCE && distance > 0 {
139 Some(Suggestion {
140 text: candidate.to_string(),
141 distance,
142 category,
143 })
144 } else {
145 None
146 }
147 })
148 .collect();
149
150 suggestions.sort_by_key(|s| s.distance);
152 suggestions.truncate(max_results);
153 suggestions
154}
155
156pub fn suggest_undefined_variable(
158 variable_name: &str,
159 available_variables: &[String],
160) -> Option<String> {
161 if variable_name == "value" {
163 return Some(
164 "Did you mean `values`? The values object is accessed as `values.key`".to_string(),
165 );
166 }
167
168 let context_match = find_closest_matches(
170 variable_name,
171 CONTEXT_VARIABLES,
172 1,
173 SuggestionCategory::Variable,
174 );
175
176 if let Some(suggestion) = context_match.first() {
177 return Some(format!("Did you mean `{}`?", suggestion.text));
178 }
179
180 let candidates: Vec<&str> = available_variables.iter().map(|s| s.as_str()).collect();
182
183 let value_match =
184 find_closest_matches(variable_name, &candidates, 3, SuggestionCategory::Variable);
185
186 if !value_match.is_empty() {
187 let suggestions: Vec<String> = value_match
188 .iter()
189 .map(|s| format!("`{}`", s.text))
190 .collect();
191 Some(format!("Did you mean {}?", suggestions.join(" or ")))
192 } else {
193 None
194 }
195}
196
197pub fn suggest_unknown_filter(filter_name: &str) -> Option<String> {
199 let matches = find_closest_matches(
200 filter_name,
201 AVAILABLE_FILTERS,
202 3,
203 SuggestionCategory::Filter,
204 );
205
206 if !matches.is_empty() {
207 let suggestions: Vec<String> = matches.iter().map(|s| format!("`{}`", s.text)).collect();
208 Some(format!(
209 "Did you mean {}? Common filters: toyaml, tojson, b64encode, quote, default, indent",
210 suggestions.join(" or ")
211 ))
212 } else {
213 Some(format!(
214 "Unknown filter `{}`. Common filters: toyaml, tojson, b64encode, quote, default, indent, nindent",
215 filter_name
216 ))
217 }
218}
219
220pub fn suggest_unknown_function(func_name: &str) -> Option<String> {
222 let matches = find_closest_matches(
223 func_name,
224 AVAILABLE_FUNCTIONS,
225 3,
226 SuggestionCategory::Function,
227 );
228
229 if !matches.is_empty() {
230 let suggestions: Vec<String> = matches.iter().map(|s| format!("`{}`", s.text)).collect();
231 Some(format!("Did you mean {}?", suggestions.join(" or ")))
232 } else {
233 Some(format!(
234 "Unknown function `{}`. Available functions: {}",
235 func_name,
236 AVAILABLE_FUNCTIONS.join(", ")
237 ))
238 }
239}
240
241pub fn extract_available_keys(values: &JsonValue, path: &str) -> Vec<String> {
243 let parts: Vec<&str> = path.split('.').filter(|s| !s.is_empty()).collect();
244
245 let mut current = values;
246 for part in &parts {
247 match current.get(part) {
248 Some(v) => current = v,
249 None => return vec![],
250 }
251 }
252
253 match current {
254 JsonValue::Object(map) => map.keys().cloned().collect(),
255 _ => vec![],
256 }
257}
258
259pub fn suggest_available_properties(
261 parent_path: &str,
262 attempted_key: &str,
263 values: &JsonValue,
264) -> Option<String> {
265 let available = extract_available_keys(values, parent_path);
266
267 if available.is_empty() {
268 return None;
269 }
270
271 let candidates: Vec<&str> = available.iter().map(|s| s.as_str()).collect();
273 let matches = find_closest_matches(attempted_key, &candidates, 3, SuggestionCategory::Property);
274
275 if !matches.is_empty() {
276 let suggestions: Vec<String> = matches
277 .iter()
278 .map(|s| format!("`{}.{}`", parent_path, s.text))
279 .collect();
280 Some(format!(
281 "Did you mean {}? Available: {}",
282 suggestions.join(" or "),
283 available.join(", ")
284 ))
285 } else {
286 Some(format!(
287 "Key `{}` not found in `{}`. Available keys: {}",
288 attempted_key,
289 parent_path,
290 available.join(", ")
291 ))
292 }
293}
294
295pub fn suggest_iteration_fix(type_name: &str) -> String {
297 match type_name {
298 "object" | "map" => {
299 "Objects require `| dictsort` to iterate: `{% for key, value in obj | dictsort %}`"
300 .to_string()
301 }
302 "string" => {
303 "Strings iterate character by character. Did you mean to split it first?".to_string()
304 }
305 "null" | "none" => {
306 "Value is null/undefined. Check that it exists or use `| default([])` for empty list"
307 .to_string()
308 }
309 _ => format!(
310 "Value of type `{}` is not iterable. Use a list or add `| dictsort` for objects",
311 type_name
312 ),
313 }
314}
315
316pub fn extract_variable_name(msg: &str) -> Option<String> {
318 let patterns = [("`", "`"), ("'", "'"), ("\"", "\"")];
320
321 for (start, end) in patterns {
322 if let Some(start_idx) = msg.find(start) {
323 let rest = &msg[start_idx + start.len()..];
324 if let Some(end_idx) = rest.find(end) {
325 return Some(rest[..end_idx].to_string());
326 }
327 }
328 }
329 None
330}
331
332pub fn extract_filter_name(msg: &str) -> Option<String> {
334 extract_variable_name(msg)
335}
336
337pub fn extract_function_name(msg: &str) -> Option<String> {
339 extract_variable_name(msg)
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345
346 #[test]
347 fn test_levenshtein_distance() {
348 assert_eq!(levenshtein("value", "values"), 1);
349 assert_eq!(levenshtein("toyml", "toyaml"), 1);
350 assert_eq!(levenshtein("b64encode", "b64encode"), 0);
351 assert_eq!(levenshtein("something", "completely"), 7);
353 }
354
355 #[test]
356 fn test_find_closest_matches() {
357 let matches =
358 find_closest_matches("toyml", AVAILABLE_FILTERS, 3, SuggestionCategory::Filter);
359 assert!(!matches.is_empty());
360 assert_eq!(matches[0].text, "toyaml");
361 assert_eq!(matches[0].distance, 1);
362 }
363
364 #[test]
365 fn test_suggest_undefined_variable_typo() {
366 let suggestion = suggest_undefined_variable("value", &[]);
367 assert!(suggestion.is_some());
368 assert!(suggestion.unwrap().contains("values"));
369 }
370
371 #[test]
372 fn test_suggest_unknown_filter() {
373 let suggestion = suggest_unknown_filter("toyml");
374 assert!(suggestion.is_some());
375 assert!(suggestion.unwrap().contains("toyaml"));
376 }
377
378 #[test]
379 fn test_extract_available_keys() {
380 let values = serde_json::json!({
381 "image": {
382 "repository": "nginx",
383 "tag": "latest"
384 },
385 "replicas": 3
386 });
387
388 let keys = extract_available_keys(&values, "image");
389 assert!(keys.contains(&"repository".to_string()));
390 assert!(keys.contains(&"tag".to_string()));
391 }
392
393 #[test]
394 fn test_extract_variable_name() {
395 assert_eq!(
396 extract_variable_name("undefined variable `foo`"),
397 Some("foo".to_string())
398 );
399 assert_eq!(
400 extract_variable_name("variable 'bar' is undefined"),
401 Some("bar".to_string())
402 );
403 }
404}