Skip to main content

scythe_codegen/
backend_options.rs

1use std::collections::HashMap;
2
3use scythe_core::errors::{ErrorCode, ScytheError};
4
5/// Reject any key in `options` that is not in `known`.
6///
7/// Originally only the TypeScript backends called this -- each one's
8/// `apply_options` did it as the first line, before touching any individual
9/// option -- while every other backend inherited the
10/// [`crate::backend_trait::CodegenBackend::apply_options`] default of
11/// `Ok(())`, so a typo like `field_casing = "camelCase"` (meant to be
12/// `field_case`) was silently discarded on, say, `java-jdbc` while it was a
13/// hard error on `typescript-pg`. The same typo behaving differently
14/// depending on target language was itself a trap (#103), so the trait
15/// default now calls this too, against a backend's declared option list --
16/// see `CodegenBackend::apply_options`. Lives outside `backends::` (a
17/// per-language module tree) because both TypeScript and non-TypeScript
18/// backends call it directly.
19///
20/// When a rejected key is within edit distance 2 of a known one, the error
21/// suggests it -- close enough to catch `row_typ` -> `row_type` or
22/// `outer_join_union` -> `outer_join_unions` without false-positiving on
23/// genuinely unrelated keys.
24pub fn reject_unknown_options(known: &[&str], options: &HashMap<String, String>) -> Result<(), ScytheError> {
25    let mut keys: Vec<&String> = options.keys().collect();
26    keys.sort();
27
28    for key in keys {
29        if known.contains(&key.as_str()) {
30            continue;
31        }
32
33        let suggestion = known
34            .iter()
35            .map(|&candidate| (candidate, levenshtein_distance(key, candidate)))
36            .filter(|&(_, distance)| distance <= 2)
37            .min_by_key(|&(_, distance)| distance)
38            .map(|(candidate, _)| candidate);
39
40        let message = match suggestion {
41            Some(suggestion) => format!(
42                "unknown option '{key}' (did you mean '{suggestion}'?): valid options are {}",
43                known.join(", ")
44            ),
45            None => format!("unknown option '{key}': valid options are {}", known.join(", ")),
46        };
47        return Err(ScytheError::new(ErrorCode::InvalidConfig, message));
48    }
49
50    Ok(())
51}
52
53/// Levenshtein edit distance between two strings, used by
54/// [`reject_unknown_options`] to suggest a likely intended option name.
55fn levenshtein_distance(a: &str, b: &str) -> usize {
56    let a: Vec<char> = a.chars().collect();
57    let b: Vec<char> = b.chars().collect();
58
59    let mut prev_row: Vec<usize> = (0..=b.len()).collect();
60    let mut curr_row = vec![0usize; b.len() + 1];
61
62    for (i, &char_a) in a.iter().enumerate() {
63        curr_row[0] = i + 1;
64        for (j, &char_b) in b.iter().enumerate() {
65            let substitution_cost = usize::from(char_a != char_b);
66            curr_row[j + 1] = (prev_row[j + 1] + 1)
67                .min(curr_row[j] + 1)
68                .min(prev_row[j] + substitution_cost);
69        }
70        std::mem::swap(&mut prev_row, &mut curr_row);
71    }
72
73    prev_row[b.len()]
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    fn known_options() -> &'static [&'static str] {
81        &["row_type", "outer_join_unions", "structs_only", "field_case"]
82    }
83
84    #[test]
85    fn test_reject_unknown_options_accepts_known_keys() {
86        let mut options = HashMap::new();
87        options.insert("row_type".to_string(), "zod".to_string());
88        options.insert("field_case".to_string(), "camelCase".to_string());
89        reject_unknown_options(known_options(), &options).unwrap();
90    }
91
92    #[test]
93    fn test_reject_unknown_options_accepts_empty_map() {
94        reject_unknown_options(known_options(), &HashMap::new()).unwrap();
95    }
96
97    /// This must fail before `reject_unknown_options` existed: the default
98    /// `apply_options` returned `Ok(())` for any options map, so a typo like
99    /// `row_typ = "zod"` silently parsed as valid TOML and had no effect.
100    #[test]
101    fn test_reject_unknown_options_rejects_unrecognized_key() {
102        let mut options = HashMap::new();
103        options.insert("row_typ".to_string(), "zod".to_string());
104        let err = reject_unknown_options(known_options(), &options).expect_err("row_typ is not a known option");
105        let message = err.to_string();
106        assert!(message.contains("row_typ"), "{message}");
107        assert!(
108            message.contains("row_type"),
109            "error should list valid options: {message}"
110        );
111    }
112
113    #[test]
114    fn test_reject_unknown_options_suggests_close_typo() {
115        let mut options = HashMap::new();
116        options.insert("row_typ".to_string(), "zod".to_string());
117        let err = reject_unknown_options(known_options(), &options).expect_err("row_typ is not a known option");
118        assert!(
119            err.to_string().contains("did you mean 'row_type'?"),
120            "expected a did-you-mean suggestion: {err}"
121        );
122    }
123
124    #[test]
125    fn test_reject_unknown_options_no_suggestion_when_too_far() {
126        let mut options = HashMap::new();
127        options.insert("completely_unrelated_option".to_string(), "x".to_string());
128        let err = reject_unknown_options(known_options(), &options).expect_err("not a known option");
129        assert!(
130            !err.to_string().contains("did you mean"),
131            "should not suggest anything this far off: {err}"
132        );
133    }
134
135    #[test]
136    fn test_levenshtein_distance_basic_cases() {
137        assert_eq!(levenshtein_distance("row_type", "row_type"), 0);
138        assert_eq!(levenshtein_distance("row_typ", "row_type"), 1);
139        assert_eq!(levenshtein_distance("", "abc"), 3);
140        assert_eq!(levenshtein_distance("abc", ""), 3);
141        assert_eq!(levenshtein_distance("kitten", "sitting"), 3);
142    }
143
144    /// A user's typo is the user's problem to fix, not evidence that scythe
145    /// broke. Reporting it as `INTERNAL_ERROR` reads as "file a bug" for an
146    /// error scythe diagnosed perfectly, so the code has to stay
147    /// `InvalidConfig` even though the message itself never changes.
148    #[test]
149    fn should_classify_unknown_option_as_invalid_config_not_internal() {
150        let mut options = HashMap::new();
151        options.insert("row_typ".to_string(), "zod".to_string());
152
153        let err = reject_unknown_options(&["row_type", "field_case"], &options)
154            .expect_err("a misspelled option key must be rejected");
155
156        assert_eq!(err.code, ErrorCode::InvalidConfig);
157        assert_eq!(err.code.to_string(), "INVALID_CONFIG");
158        assert!(
159            err.message.contains("did you mean 'row_type'?"),
160            "the suggestion is the useful half of the message: {}",
161            err.message
162        );
163    }
164
165    /// A backend with no options at all (the trait default) must reject any
166    /// key, not silently accept it -- the exact protection #103 asks for on
167    /// every backend, not just the eleven TypeScript ones.
168    #[test]
169    fn test_reject_unknown_options_rejects_any_key_against_an_empty_known_set() {
170        let mut options = HashMap::new();
171        options.insert("row_type".to_string(), "zod".to_string());
172        let err = reject_unknown_options(&[], &options).expect_err("a backend with no options accepts no keys");
173        assert!(err.to_string().contains("row_type"), "{err}");
174        assert_eq!(err.code, ErrorCode::InvalidConfig);
175    }
176}