1pub use crate::editing::FmlEditorError;
6use crate::{
7 editing::{CursorPosition, ErrorConverter},
8 error::{ClientError, FMLError, Result},
9 intermediate_representation::{FeatureDef, FeatureExample, FeatureManifest},
10 FmlClient, JsonObject,
11};
12use serde_json::Value;
13use std::sync::Arc;
14use url::Url;
15
16impl FmlClient {
17 pub fn get_feature_inspector(&self, id: String) -> Option<Arc<FmlFeatureInspector>> {
18 _ = self.manifest.find_feature(&id)?;
19 Some(Arc::new(FmlFeatureInspector::new(
20 self.manifest.clone(),
21 id,
22 )))
23 }
24}
25
26pub struct FmlFeatureInspector {
27 manifest: Arc<FeatureManifest>,
28 feature_id: String,
29}
30
31impl FmlFeatureInspector {
32 pub(crate) fn new(manifest: Arc<FeatureManifest>, feature_id: String) -> Self {
33 Self {
34 manifest,
35 feature_id,
36 }
37 }
38
39 pub fn get_default_json(&self) -> Result<JsonObject> {
40 let f = self.get_feature();
41
42 match f.default_json() {
43 Value::Object(map) => Ok(map),
44 _ => Err(FMLError::ClientError(ClientError::InvalidFeatureValue(
45 "A non-JSON object is returned as default. This is likely a Nimbus FML bug."
46 .to_string(),
47 ))),
48 }
49 }
50
51 pub fn get_examples(&self) -> Result<Vec<FmlFeatureExample>> {
52 let feature_examples = &self.get_feature().examples;
53 let mut examples: Vec<FmlFeatureExample> = Vec::with_capacity(feature_examples.len() + 1);
54 examples.extend(feature_examples.clone().into_iter().map(Into::into).rev());
56
57 examples.push(FmlFeatureExample {
60 name: String::from("Default configuration (in full)"),
61 value: self.get_default_json()?,
62 ..Default::default()
63 });
64
65 Ok(examples)
66 }
67
68 pub fn get_errors(&self, string: String) -> Option<Vec<FmlEditorError>> {
69 match self.parse_json_string(&string) {
70 Err(e) => Some(vec![e]),
71 Ok(json) => {
72 let errors = self.get_semantic_errors(&string, json);
73 if errors.is_empty() {
74 None
75 } else {
76 Some(errors)
77 }
78 }
79 }
80 }
81
82 pub fn get_schema_hash(&self) -> String {
83 let (fm, f) = self.get_manifest_and_feature();
84 fm.feature_schema_hash(f)
85 }
86
87 pub fn get_defaults_hash(&self) -> String {
88 let (fm, f) = self.get_manifest_and_feature();
89 fm.feature_defaults_hash(f)
90 }
91}
92
93impl FmlFeatureInspector {
94 fn get_feature(&self) -> &FeatureDef {
95 self.get_manifest_and_feature().1
96 }
97
98 fn _get_manifest(&self) -> &FeatureManifest {
99 self.get_manifest_and_feature().0
100 }
101
102 fn get_manifest_and_feature(&self) -> (&FeatureManifest, &FeatureDef) {
103 self.manifest
104 .find_feature(&self.feature_id)
105 .expect("We construct an inspector with a feature_id, so this should be impossible")
106 }
107
108 fn parse_json_string(&self, string: &str) -> Result<Value, FmlEditorError> {
109 Ok(match serde_json::from_str::<Value>(string) {
110 Ok(json) if json.is_object() => json,
111 Ok(_) => syntax_error("Need valid JSON object", 0, 0, string)?,
112 Err(e) => {
113 let col = e.column();
114 syntax_error(
115 "Need valid JSON object",
116 e.line() - 1,
117 if col == 0 { 0 } else { col - 1 },
118 "",
119 )?
120 }
121 })
122 }
123
124 fn get_semantic_errors(&self, src: &str, value: Value) -> Vec<FmlEditorError> {
125 let (manifest, feature_def) = self.get_manifest_and_feature();
126 let (merged_value, errors) = manifest.merge_and_errors(feature_def, &value);
127 if !errors.is_empty() {
128 let converter = ErrorConverter::new(&manifest.enum_defs, &manifest.obj_defs);
129 converter.convert_into_editor_errors(feature_def, &merged_value, src, &errors)
130 } else {
131 Default::default()
132 }
133 }
134}
135
136fn syntax_error(
137 message: &str,
138 line: usize,
139 col: usize,
140 highlight: &str,
141) -> Result<Value, FmlEditorError> {
142 let error_span = CursorPosition::new(line, col) + highlight;
143 Err(FmlEditorError {
144 message: String::from(message),
145 error_span,
146 line: line as u32,
147 col: col as u32,
148 ..Default::default()
149 })
150}
151
152#[derive(Default)]
153pub struct FmlFeatureExample {
154 pub name: String,
155 pub description: Option<String>,
156 pub url: Option<Url>,
157 pub value: JsonObject,
158}
159
160impl From<FeatureExample> for FmlFeatureExample {
161 fn from(example: FeatureExample) -> Self {
162 let metadata = example.metadata;
163 Self {
164 name: metadata.name,
165 description: metadata.description,
166 url: metadata.url,
167 value: match example.value {
168 Value::Object(v) => v,
169 _ => Default::default(),
170 },
171 }
172 }
173}
174
175#[cfg(test)]
176mod unit_tests {
177 use crate::{client::test_helper::client, editing::FmlEditorError};
178
179 use super::*;
180
181 impl FmlFeatureInspector {
182 pub(crate) fn get_first_error(&self, string: String) -> Option<FmlEditorError> {
183 let mut errors = self.get_errors(string)?;
184 errors.pop()
185 }
186 }
187
188 #[test]
189 fn test_construction() -> Result<()> {
190 let client = client("./nimbus_features.yaml", "release")?;
191 assert_eq!(
192 client.get_feature_ids(),
193 vec!["dialog-appearance".to_string()]
194 );
195 let f = client.get_feature_inspector("dialog-appearance".to_string());
196 assert!(f.is_some());
197
198 let f = client.get_feature_inspector("not-there".to_string());
199 assert!(f.is_none());
200
201 Ok(())
202 }
203
204 #[test]
205 fn test_get_first_error_invalid_json() -> Result<()> {
206 let client = client("./nimbus_features.yaml", "release")?;
207 let f = client
208 .get_feature_inspector("dialog-appearance".to_string())
209 .unwrap();
210
211 fn test_syntax_error(
212 inspector: &FmlFeatureInspector,
213 input: &str,
214 col: usize,
215 highlight: bool,
216 ) {
217 let error = inspector
218 .get_first_error(input.to_string())
219 .unwrap_or_else(|| unreachable!("No error for '{input}'"));
220 let highlight = if highlight { input } else { "" };
221 assert_eq!(
222 error,
223 syntax_error("Need valid JSON object", 0, col, highlight).unwrap_err()
224 );
225 }
226
227 test_syntax_error(&f, "", 0, false);
228 test_syntax_error(&f, "x", 0, false);
229 test_syntax_error(&f, "{ \"\" }, ", 5, false);
230 test_syntax_error(&f, "{ \"foo\":", 7, false);
231
232 test_syntax_error(&f, "[]", 0, true);
233 test_syntax_error(&f, "1", 0, true);
234 test_syntax_error(&f, "true", 0, true);
235 test_syntax_error(&f, "\"string\"", 0, true);
236
237 assert!(f.get_first_error("{}".to_string()).is_none());
238 Ok(())
239 }
240
241 #[test]
242 fn test_get_first_error_type_invalid() -> Result<()> {
243 let client = client("./nimbus_features.yaml", "release")?;
244 let f = client
245 .get_feature_inspector("dialog-appearance".to_string())
246 .unwrap();
247
248 let s = r#"{}"#;
249 assert!(f.get_first_error(s.to_string()).is_none());
250 let s = r#"{
251 "positive": {}
252 }"#;
253 assert!(f.get_first_error(s.to_string()).is_none());
254
255 let s = r#"{
256 "positive": 1
257 }"#;
258 if let Some(_err) = f.get_first_error(s.to_string()) {
259 } else {
260 unreachable!("No error for \"{s}\"");
261 }
262
263 let s = r#"{
264 "positive1": {}
265 }"#;
266 if let Some(_err) = f.get_first_error(s.to_string()) {
267 } else {
268 unreachable!("No error for \"{s}\"");
269 }
270
271 Ok(())
272 }
273
274 #[test]
275 fn test_deterministic_errors() -> Result<()> {
276 let client = client("./nimbus_features.yaml", "release")?;
277 let inspector = client
278 .get_feature_inspector("dialog-appearance".to_string())
279 .unwrap();
280
281 let s = r#"{
282 "positive": { "yes" : { "trait": 1 } }
283 }"#;
284 let err1 = inspector
285 .get_first_error(s.to_string())
286 .unwrap_or_else(|| unreachable!("No error for \"{s}\""));
287
288 let err2 = inspector
289 .get_first_error(s.to_string())
290 .unwrap_or_else(|| unreachable!("No error for \"{s}\""));
291
292 assert_eq!(err1, err2);
293
294 Ok(())
295 }
296
297 #[test]
298 fn test_semantic_errors() -> Result<()> {
299 let client = client("./browser.yaml", "release")?;
300 let inspector = client
301 .get_feature_inspector("nimbus-validation".to_string())
302 .unwrap();
303
304 let do_test = |lines: &[&str], token: &str, expected: (u32, u32)| {
305 let input = lines.join("\n");
306 let err = inspector
307 .get_first_error(input.clone())
308 .unwrap_or_else(|| unreachable!("No error for \"{input}\""));
309
310 assert_eq!(
311 err.highlight,
312 Some(token.to_string()),
313 "Token {token} not detected in error in {input}"
314 );
315
316 let observed = (err.error_span.from.line, err.error_span.from.col);
317 assert_eq!(
318 expected, observed,
319 "Error at {token} in the wrong place in {input}"
320 );
321 };
322
323 do_test(
325 &[
326 r#"{"#, r#" "invalid": 1"#, r#"}"#, ],
331 "\"invalid\"",
332 (1, 2),
333 );
334
335 do_test(
337 &[
338 r#"{"#, r#" "icon-type": 1"#, r#"}"#, ],
343 "1",
344 (1, 15),
345 );
346
347 do_test(
349 &[
350 r#"{"#, r#" "icon-type": "invalid""#, r#"}"#, ],
355 "\"invalid\"",
356 (1, 15),
357 );
358
359 do_test(
361 &[
362 r#"{"#, r#" "nested": {"#, r#" "invalid": true"#, r#" }"#, r#"}"#, ],
369 "\"invalid\"",
370 (2, 4),
371 );
372
373 do_test(
375 &[
376 r#"{"#, r#" "nested": {"#, r#" "is-useful": 256"#, r#" }"#, r#"}"#, ],
383 "256",
384 (2, 17),
385 );
386
387 do_test(
389 &[
390 r#"{"#, r#" "string-int-map": {"#, r#" "valid": "invalid""#, r#" }"#, r#"}"#, ],
397 "\"invalid\"",
398 (2, 13),
399 );
400
401 do_test(
403 &[
404 r#"{"#, r#" "enum-map": {"#, r#" "invalid": 42"#, r#" }"#, r#"}"#, ],
411 "\"invalid\"",
412 (2, 4),
413 );
414
415 do_test(
417 &[
418 r#"{"#, r#" "nested-list": ["#, r#" {"#, r#" "is-useful": true"#, r#" },"#, r#" false"#, r#" ]"#, r#"}"#, ],
428 "false",
429 (5, 5),
430 );
431
432 do_test(
434 &[
435 r#"{"#, r#" "string-int-map": {"#, r#" "nested": 1,"#, r#" "is-useful": 2,"#, r#" "invalid": 3"#, r#" },"#, r#" "nested": {"#, r#" "is-useful": "invalid""#, r#" }"#, r#"}"#, ],
447 "\"invalid\"",
448 (7, 17),
449 );
450
451 Ok(())
452 }
453}
454
455#[cfg(test)]
456mod correction_candidates {
457 use crate::{
458 client::test_helper::client,
459 editing::{CorrectionCandidate, CursorSpan},
460 };
461
462 use super::*;
463
464 fn perform_correction(
466 lines: &[&str],
467 position: &CursorSpan,
468 correction: &CorrectionCandidate,
469 ) -> String {
470 let position = correction.insertion_span.as_ref().unwrap_or(position);
471 position.insert_str(lines, &correction.insert)
472 }
473
474 fn try_correcting_single_error(inspector: &FmlFeatureInspector, lines: &[&str]) {
489 let input = lines.join("\n");
490 let err = inspector.get_first_error(input.clone());
491 assert_ne!(None, err, "No error found in input: {input}");
492 let err = err.unwrap();
493 assert_ne!(
494 0,
495 err.corrections.len(),
496 "No corrections for {input}: {err:?}"
497 );
498
499 for correction in &err.corrections {
500 let input = perform_correction(lines, &err.error_span, correction);
501 let err = inspector.get_first_error(input.clone());
502 assert_eq!(None, err, "Error found in {input}");
503 }
504 }
505
506 #[test]
507 fn test_correction_candidates_placeholders_scalar() -> Result<()> {
508 let fm = client("./browser.yaml", "release")?;
509
510 let inspector = fm
511 .get_feature_inspector("search-term-groups".to_string())
512 .unwrap();
513 try_correcting_single_error(
515 &inspector,
516 &[
517 r#"{"#, r#" "enabled": 1"#, r#"}"#, ],
522 );
523
524 let inspector = fm
525 .get_feature_inspector("nimbus-validation".to_string())
526 .unwrap();
527
528 try_correcting_single_error(
530 &inspector,
531 &[
532 r#"{"#, r#" "settings-punctuation": 1"#, r#"}"#, ],
537 );
538
539 try_correcting_single_error(
541 &inspector,
542 &[
543 r#"{"#, r#" "settings-icon": 1"#, r#"}"#, ],
548 );
549
550 try_correcting_single_error(
552 &inspector,
553 &[
554 r#"{"#, r#" "string-int-map": { "#, r#" "valid": "not-valid" "#, r#" }"#, r#"}"#, ],
561 );
562 Ok(())
563 }
564
565 #[test]
566 fn test_correction_candidates_replacing_structural() -> Result<()> {
567 let fm = client("./browser.yaml", "release")?;
568 let inspector = fm
569 .get_feature_inspector("nimbus-validation".to_string())
570 .unwrap();
571
572 try_correcting_single_error(
574 &inspector,
575 &[
576 r#"{"#, r#" "settings-punctuation": {}"#, r#"}"#, ],
581 );
582
583 try_correcting_single_error(
585 &inspector,
586 &[
587 r#"{"#, r#" "settings-punctuation": []"#, r#"}"#, ],
592 );
593
594 try_correcting_single_error(
596 &inspector,
597 &[
598 r#"{"#, r#" "settings-punctuation": ["foo"]"#, r#"}"#, ],
603 );
604
605 Ok(())
606 }
607
608 #[test]
615 fn test_correction_candidates_replacing_structural_plus_whitespace() -> Result<()> {
616 let fm = client("./browser.yaml", "release")?;
617 let inspector = fm
618 .get_feature_inspector("nimbus-validation".to_string())
619 .unwrap();
620
621 try_correcting_single_error(
623 &inspector,
624 &[
625 r#"{"#, r#" "settings-punctuation": { }"#, r#"}"#, ],
630 );
631
632 try_correcting_single_error(
634 &inspector,
635 &[
636 r#"{"#, r#" "settings-punctuation": [ ]"#, r#"}"#, ],
641 );
642
643 try_correcting_single_error(
645 &inspector,
646 &[
647 r#"{"#, r#" "settings-punctuation": [ "foo"]"#, r#"}"#, ],
652 );
653
654 Ok(())
655 }
656
657 #[test]
658 fn test_correction_candidates_placeholders_structural() -> Result<()> {
659 let fm = client("./browser.yaml", "release")?;
660 let inspector = fm
661 .get_feature_inspector("nimbus-validation".to_string())
662 .unwrap();
663
664 try_correcting_single_error(
666 &inspector,
667 &[
668 r#"{"#, r#" "settings-title": true"#, r#"}"#, ],
673 );
674
675 try_correcting_single_error(
677 &inspector,
678 &[
679 r#"{"#, r#" "string-map": 1"#, r#"}"#, ],
684 );
685
686 try_correcting_single_error(
688 &inspector,
689 &[
690 r#"{"#, r#" "nested": 1"#, r#"}"#, ],
695 );
696
697 try_correcting_single_error(
699 &inspector,
700 &[
701 r#"{"#, r#" "nested-optional": 1"#, r#"}"#, ],
706 );
707
708 try_correcting_single_error(
710 &inspector,
711 &[
712 r#"{"#, r#" "nested-list": 1"#, r#"}"#, ],
717 );
718
719 try_correcting_single_error(
721 &inspector,
722 &[
723 r#"{"#, r#" "nested-list": [1]"#, r#"}"#, ],
728 );
729
730 Ok(())
731 }
732
733 #[test]
734 fn test_correction_candidates_property_keys() -> Result<()> {
735 let fm = client("./browser.yaml", "release")?;
736 let inspector = fm.get_feature_inspector("homescreen".to_string()).unwrap();
737
738 try_correcting_single_error(
739 &inspector,
740 &[
741 r#"{"#, r#" "invalid": {}"#, r#"}"#, ],
746 );
747 Ok(())
748 }
749
750 #[test]
751 fn test_correction_candidates_enum_strings() -> Result<()> {
752 let fm = client("./enums.fml.yaml", "release")?;
753 let inspector = fm
754 .get_feature_inspector("my-coverall-feature".to_string())
755 .unwrap();
756
757 try_correcting_single_error(
758 &inspector,
759 &[
760 r#"{"#, r#" "scalar": true"#, r#"}"#, ],
765 );
766
767 try_correcting_single_error(
768 &inspector,
769 &[
770 r#"{"#, r#" "scalar": 13"#, r#"}"#, ],
775 );
776
777 try_correcting_single_error(
778 &inspector,
779 &[
780 r#"{"#, r#" "list": [13]"#, r#"}"#, ],
785 );
786
787 try_correcting_single_error(
788 &inspector,
789 &[
790 r#"{"#, r#" "list": ["top", 13 ]"#, r#"}"#, ],
795 );
796
797 try_correcting_single_error(
798 &inspector,
799 &[
800 r#"{"#, r#" "list": [ false ]"#, r#"}"#, ],
805 );
806
807 try_correcting_single_error(
808 &inspector,
809 &[
810 r#"{"#, r#" "list": ["top", false ]"#, r#"}"#, ],
815 );
816
817 try_correcting_single_error(
818 &inspector,
819 &[
820 r#"{"#, r#" "map": { "invalid": false }"#, r#"}"#, ],
825 );
826
827 try_correcting_single_error(
828 &inspector,
829 &[
830 r#"{"#, r#" "map": { "#, r#" "top": false, "#, r#" "invalid": false "#, r#" } "#, r#"}"#, ],
838 );
839
840 Ok(())
841 }
842
843 #[test]
844 fn test_correction_candidates_string_aliases() -> Result<()> {
845 let fm = client("string-aliases.fml.yaml", "storms")?;
846 let inspector = fm
847 .get_feature_inspector("my-coverall-team".to_string())
848 .unwrap();
849
850 try_correcting_single_error(
851 &inspector,
852 &[
853 r#"{ "#, r#" "players": [ "#, r#" "Shrek", "#, r#" "Fiona" "#, r#" ], "#, r#" "top-player": true "#, r#"}"#, ],
862 );
863
864 try_correcting_single_error(
865 &inspector,
866 &[
867 r#"{ "#, r#" "players": [ "#, r#" "Shrek", "#, r#" "Fiona" "#, r#" ], "#, r#" "top-player": "Donkey""#, r#"}"#, ],
876 );
877
878 try_correcting_single_error(
879 &inspector,
880 &[
881 r#"{ "#, r#" "players": [ "#, r#" "Shrek", "#, r#" "Fiona" "#, r#" ], "#, r#" "availability": { "#, r#" "Donkey": true "#, r#" }"#, r#"}"#, ],
892 );
893
894 try_correcting_single_error(
895 &inspector,
896 &[
897 r#"{ "#, r#" "players": [ "#, r#" "Shrek", "#, r#" "Fiona" "#, r#" ], "#, r#" "availability": { "#, r#" "Shrek": true,"#, r#" "Donkey": true "#, r#" }"#, r#"}"#, ],
909 );
910
911 try_correcting_single_error(
912 &inspector,
913 &[
914 r#"{ "#, r#" "players": [ "#, r#" "Shrek", "#, r#" "Fiona" "#, r#" ], "#, r#" "availability": { "#, r#" "Fiona": true, "#, r#" "invalid": true "#, r#" }"#, r#"}"#, ],
926 );
927
928 Ok(())
929 }
930}
931
932#[cfg(test)]
933mod config_examples {
934 use super::*;
935 use crate::client::test_helper::client;
936
937 #[test]
938 fn smoke_test() -> Result<()> {
939 let fm = client("./config-examples/app.fml.yaml", "release")?;
940 let inspector = fm
941 .get_feature_inspector(String::from("my-component-feature"))
942 .unwrap();
943
944 let examples = inspector.get_examples()?;
945
946 assert_eq!(examples.len(), 5);
947 let names: Vec<_> = examples.iter().map(|ex| ex.name.as_str()).collect();
948 assert_eq!(
949 &[
950 "4. Partial example with JSON for imported feature",
951 "3. Inlined example for imported feature",
952 "2. An example from a file adjacent to the component",
953 "1. Inlined example for feature",
954 "Default configuration (in full)",
955 ],
956 names.as_slice()
957 );
958
959 Ok(())
960 }
961
962 #[test]
963 fn validating_test() -> Result<()> {
964 let res = client(
965 "./config-examples/app-with-broken-example.fml.yaml",
966 "release",
967 );
968 assert!(res.is_err());
969
970 let is_validation_err = matches!(
971 res.err().unwrap(),
972 FMLError::ValidationError(path, message) if
973 path.as_str() ==
974 "features/my-component-feature#examples[\"Broken example with invalid-property\"]"
975 && message.starts_with(
976 "Invalid property \"invalid-property\""));
977 assert!(is_validation_err);
978
979 Ok(())
980 }
981}