nimbus_fml/editing/
error_path.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2* License, v. 2.0. If a copy of the MPL was not distributed with this
3* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5use serde_json::Value;
6
7/// The `ErrorPath` struct is constructed in the default validator to be used
8/// to derive where an error has been detected.
9///
10/// serde_yaml does not keep track of lines and columns so we need to be able to
11/// indicate where an error takes place.
12///
13/// For reporting errors in the manifest on the command line, an error might have a path such as:
14///
15///  1. `features/messaging.messages['my-message'].MessageData#is-control` expects a boolean,
16///  2. `features/homescreen.sections-enabled[HomeScreenSection#pocket]` expects a boolean
17///  3. `objects/AwesomeBar.min-search-term`.
18///
19/// The path to an error is given by `&self.path`.
20///
21/// The defaults validation is exactly the same as the validation performed on the Feature Configuration
22/// JSON in experimenter. Thus, `literals` is a `Vec<String>` of tokens found in JSON, which should in
23/// almost all circumstances lead to the correct token being identified by line and column.
24///
25/// So the corresponding `literals` of a type mismatch error where an integer `1` is used instead
26/// of a boolean would be:
27///
28///  1. `"messages"`, `{`, `"my-message"`, `"is-control"`, `1`
29///  2. `"sections-enabled"`, `{`, `"pocket"`, `1`
30///
31/// `find_err(src: &str)` is used to find the line and column for the final `1` token.
32/// Currently `find_err` exists in `inspector.rs`, but this should move (along with reduced visibility
33/// of `literals`) in a future commit.
34#[derive(Clone)]
35pub(crate) struct ErrorPath {
36    start_index: Option<usize>,
37    literals: Vec<String>,
38    pub(crate) path: String,
39}
40
41/// Chained Constructors
42impl ErrorPath {
43    fn new(path: String, literals: Vec<String>) -> Self {
44        Self {
45            path,
46            literals,
47            start_index: None,
48        }
49    }
50
51    pub(crate) fn feature(name: &str) -> Self {
52        Self::new(format!("features/{name}"), Default::default())
53    }
54
55    pub(crate) fn object(name: &str) -> Self {
56        Self::new(format!("objects/{name}"), Default::default())
57    }
58
59    pub(crate) fn example(&self, name: &str) -> Self {
60        Self::new(
61            format!("{}#examples[\"{name}\"]", &self.path),
62            self.literals.clone(),
63        )
64    }
65
66    pub(crate) fn property(&self, prop_key: &str) -> Self {
67        Self::new(
68            format!("{}.{prop_key}", &self.path),
69            append_quoted(&self.literals, prop_key),
70        )
71    }
72
73    pub(crate) fn enum_map_key(&self, enum_: &str, key: &str) -> Self {
74        Self::new(
75            format!("{}[{enum_}#{key}]", &self.path),
76            append(&self.literals, &["{".to_string(), format!("\"{key}\"")]),
77        )
78    }
79
80    pub(crate) fn map_key(&self, key: &str) -> Self {
81        Self::new(
82            format!("{}['{key}']", &self.path),
83            append(&self.literals, &["{".to_string(), format!("\"{key}\"")]),
84        )
85    }
86
87    pub(crate) fn array_index(&self, index: usize) -> Self {
88        let mut literals = append1(&self.literals, "[");
89        if index > 0 {
90            literals.extend_from_slice(&[",".repeat(index)]);
91        }
92        Self::new(format!("{}[{index}]", &self.path), literals)
93    }
94
95    pub(crate) fn object_value(&self, name: &str) -> Self {
96        Self::new(
97            format!("{}#{name}", &self.path),
98            append1(&self.literals, "{"),
99        )
100    }
101
102    pub(crate) fn open_brace(&self) -> Self {
103        Self::new(self.path.clone(), append1(&self.literals, "{"))
104    }
105
106    pub(crate) fn final_error_quoted(&self, highlight: &str) -> Self {
107        Self::new(self.path.clone(), append_quoted(&self.literals, highlight))
108    }
109
110    pub(crate) fn final_error_value(&self, value: &Value) -> Self {
111        let len = self.literals.len();
112        let mut literals = Vec::with_capacity(len * 2);
113        literals.extend_from_slice(self.literals.as_slice());
114        collect_path(&mut literals, value);
115
116        Self {
117            path: self.path.clone(),
118            literals,
119            start_index: Some(len),
120        }
121    }
122}
123
124fn collect_path(literals: &mut Vec<String>, value: &Value) {
125    match value {
126        Value::Bool(_) | Value::Number(_) | Value::Null => literals.push(value.to_string()),
127        Value::String(s) => literals.push(format!("\"{s}\"")),
128
129        Value::Array(array) => {
130            literals.push(String::from("["));
131            for v in array {
132                collect_path(literals, v);
133            }
134            literals.push(String::from("]"));
135        }
136
137        Value::Object(map) => {
138            literals.push(String::from("{"));
139            if let Some((k, v)) = map.iter().next_back() {
140                literals.push(format!("\"{k}\""));
141                collect_path(literals, v);
142            }
143            literals.push(String::from("}"));
144        }
145    }
146}
147
148/// Accessors
149impl ErrorPath {
150    pub(crate) fn error_token_abbr(&self) -> String {
151        match self.start_index {
152            Some(index) if index < self.literals.len() - 1 => {
153                let start = self
154                    .literals
155                    .get(index)
156                    .map(String::as_str)
157                    .unwrap_or_default();
158                let end = self.last_error_token().unwrap();
159                format!("{start}…{end}")
160            }
161            _ => self.last_error_token().unwrap().to_owned(),
162        }
163    }
164
165    pub(crate) fn last_error_token(&self) -> Option<&str> {
166        self.literals.last().map(String::as_str)
167    }
168}
169
170#[cfg(feature = "client-lib")]
171impl ErrorPath {
172    pub(crate) fn first_error_token(&self) -> Option<&str> {
173        if let Some(index) = self.start_index {
174            self.literals.get(index).map(String::as_str)
175        } else {
176            self.last_error_token()
177        }
178    }
179
180    /// Gives the span of characters within the given source code where this error
181    /// was detected.
182    ///
183    /// Currently, this is limited to finding the last token and adding the length.
184    pub(crate) fn error_span(&self, src: &str) -> crate::editing::CursorSpan {
185        use crate::editing::CursorPosition;
186        let mut lines = src.lines().peekable();
187        let last_token = self.last_error_token().unwrap();
188        if let Some(index) = self.start_index {
189            let path_to_first = self.literals[..index + 1].iter().map(String::as_str);
190            let rest = self.literals[index + 1..].iter().map(String::as_str);
191
192            let pos = line_col_from_lines(&mut lines, (0, 0), path_to_first);
193            let from: CursorPosition = pos.into();
194
195            let to: CursorPosition = line_col_from_lines(&mut lines, pos, rest).into();
196
197            from + (to + last_token)
198        } else {
199            let from: CursorPosition =
200                line_col_from_lines(&mut lines, (0, 0), self.literals.iter().map(String::as_str))
201                    .into();
202            from + last_token
203        }
204    }
205}
206
207fn append(original: &[String], new: &[String]) -> Vec<String> {
208    let mut clone = Vec::with_capacity(original.len() + new.len());
209    clone.extend_from_slice(original);
210    clone.extend_from_slice(new);
211    clone
212}
213
214fn append1(original: &[String], new: &str) -> Vec<String> {
215    let mut clone = Vec::with_capacity(original.len() + 1);
216    clone.extend_from_slice(original);
217    clone.push(new.to_string());
218    clone
219}
220
221fn append_quoted(original: &[String], new: &str) -> Vec<String> {
222    append1(original, &format!("\"{new}\""))
223}
224
225#[cfg(feature = "client-lib")]
226fn line_col_from_lines<'a>(
227    lines: &mut std::iter::Peekable<impl Iterator<Item = &'a str>>,
228    start: (usize, usize),
229    path: impl Iterator<Item = &'a str>,
230) -> (usize, usize) {
231    let (mut line_no, mut col_no) = start;
232
233    // `first_match` is "are we looking for the first match of the line"
234    let mut first_match = col_no == 0;
235
236    for p in path {
237        loop {
238            if let Some(line) = lines.peek() {
239                // If we haven't had our first match of the line, then start there at the beginning.
240                // Otherwise, start one char on from where we were last time.
241                //
242                // We might optimize this by adding the grapheme length to col_no,
243                // but we're in the "make it right" phase.
244                let start = if first_match { 0 } else { col_no + 1 };
245
246                if let Some(i) = find_index(line, p, start) {
247                    col_no = i;
248                    first_match = false;
249                    break;
250                } else if lines.next().is_some() {
251                    // we try the next line!
252                    line_no += 1;
253                    first_match = true;
254                    col_no = 0;
255                }
256            } else {
257                // we've run out of lines, so we should return
258                return (0, 0);
259            }
260        }
261    }
262
263    (line_no, col_no)
264}
265
266/// Find the index in `line` of the next instance of `pattern`, after `start`
267///
268#[cfg(feature = "client-lib")]
269fn find_index(line: &str, pattern: &str, start: usize) -> Option<usize> {
270    use unicode_segmentation::UnicodeSegmentation;
271    let line: Vec<&str> = UnicodeSegmentation::graphemes(line, true).collect();
272    let line_from_start = &line[start..];
273
274    let pattern: Vec<&str> = UnicodeSegmentation::graphemes(pattern, true).collect();
275    let pattern = pattern.as_slice();
276
277    line_from_start
278        .windows(pattern.len())
279        .position(|window| window == pattern)
280        .map(|i| i + start)
281}
282
283#[cfg(feature = "client-lib")]
284#[cfg(test)]
285mod construction_tests {
286    use serde_json::json;
287
288    use super::ErrorPath;
289
290    #[test]
291    fn test_property() {
292        let path = ErrorPath::feature("my-feature").property("my-property");
293        assert_eq!("features/my-feature.my-property", &path.path);
294        assert_eq!(&["\"my-property\""], path.literals.as_slice());
295
296        let path = ErrorPath::object("MyObject").property("my-property");
297        assert_eq!("objects/MyObject.my-property", &path.path);
298        assert_eq!(&["\"my-property\""], path.literals.as_slice());
299    }
300
301    #[test]
302    fn test_map_key() {
303        let path = ErrorPath::feature("my-feature")
304            .property("my-map")
305            .map_key("my-key");
306        assert_eq!("features/my-feature.my-map['my-key']", &path.path);
307        assert_eq!(&["\"my-map\"", "{", "\"my-key\""], path.literals.as_slice());
308    }
309
310    #[test]
311    fn test_enum_map_key() {
312        let path = ErrorPath::feature("my-feature")
313            .property("my-map")
314            .enum_map_key("MyEnum", "my-variant");
315        assert_eq!("features/my-feature.my-map[MyEnum#my-variant]", &path.path);
316        assert_eq!(
317            &["\"my-map\"", "{", "\"my-variant\""],
318            path.literals.as_slice()
319        );
320    }
321
322    #[test]
323    fn test_array_index() {
324        let path = ErrorPath::feature("my-feature")
325            .property("my-array")
326            .array_index(1);
327        assert_eq!("features/my-feature.my-array[1]", &path.path);
328        assert_eq!(&["\"my-array\"", "[", ","], path.literals.as_slice());
329
330        let path = ErrorPath::feature("my-feature")
331            .property("my-array")
332            .array_index(0);
333        assert_eq!("features/my-feature.my-array[0]", &path.path);
334        assert_eq!(&["\"my-array\"", "["], path.literals.as_slice());
335    }
336
337    #[test]
338    fn test_object_value() {
339        let path = ErrorPath::feature("my-feature")
340            .property("my-object")
341            .object_value("MyObject");
342        assert_eq!("features/my-feature.my-object#MyObject", &path.path);
343        assert_eq!(&["\"my-object\"", "{"], path.literals.as_slice());
344    }
345
346    #[test]
347    fn test_final_error() {
348        //  1. `features/messaging.messages['my-message']#MessageData.is-control` expects a boolean,
349        let path = ErrorPath::feature("messaging")
350            .property("messages")
351            .map_key("my-message")
352            .object_value("MessageData")
353            .property("is-control")
354            .final_error_value(&json!(1));
355        assert_eq!(
356            "features/messaging.messages['my-message']#MessageData.is-control",
357            &path.path
358        );
359        assert_eq!(
360            &[
361                "\"messages\"",
362                "{",
363                "\"my-message\"",
364                "{",
365                "\"is-control\"",
366                "1"
367            ],
368            path.literals.as_slice()
369        );
370
371        //  2. `features/homescreen.sections-enabled[HomeScreenSection#pocket]` expects a boolean
372        let path = ErrorPath::feature("homescreen")
373            .property("sections-enabled")
374            .enum_map_key("HomeScreenSection", "pocket")
375            .final_error_value(&json!(1));
376        assert_eq!(
377            "features/homescreen.sections-enabled[HomeScreenSection#pocket]",
378            &path.path
379        );
380
381        assert_eq!(
382            &["\"sections-enabled\"", "{", "\"pocket\"", "1"],
383            path.literals.as_slice()
384        );
385    }
386
387    #[test]
388    fn test_final_error_value_scalars() {
389        let path = ErrorPath::feature("my-feature").property("is-enabled");
390
391        let observed = {
392            let value = json!(true);
393            path.final_error_value(&value)
394        };
395        assert_eq!(observed.literals.as_slice(), &["\"is-enabled\"", "true"]);
396
397        let observed = {
398            let value = json!(13);
399            path.final_error_value(&value)
400        };
401        assert_eq!(observed.literals.as_slice(), &["\"is-enabled\"", "13"]);
402
403        let observed = {
404            let value = json!("string");
405            path.final_error_value(&value)
406        };
407        assert_eq!(
408            observed.literals.as_slice(),
409            &["\"is-enabled\"", "\"string\""]
410        );
411    }
412
413    #[test]
414    fn test_final_error_value_arrays() {
415        let path = ErrorPath::feature("my-feature").property("is-enabled");
416
417        let observed = {
418            let value = json!([]);
419            let o = path.final_error_value(&value);
420            assert_eq!(o.first_error_token(), Some("["));
421            o
422        };
423        assert_eq!(observed.literals.as_slice(), &["\"is-enabled\"", "[", "]"]);
424
425        let observed = {
426            let value = json!([1, 2]);
427            let o = path.final_error_value(&value);
428            assert_eq!(o.first_error_token(), Some("["));
429            o
430        };
431        assert_eq!(
432            observed.literals.as_slice(),
433            &["\"is-enabled\"", "[", "1", "2", "]"]
434        );
435    }
436
437    #[test]
438    fn test_final_error_value_objects() {
439        let path = ErrorPath::feature("my-feature").property("is-enabled");
440
441        let observed = {
442            let value = json!({});
443            let o = path.final_error_value(&value);
444            assert_eq!(o.first_error_token(), Some("{"));
445            o
446        };
447        assert_eq!(observed.literals.as_slice(), &["\"is-enabled\"", "{", "}"]);
448
449        let observed = {
450            let value = json!({"last": true});
451            let o = path.final_error_value(&value);
452            assert_eq!(o.first_error_token(), Some("{"));
453            o
454        };
455        assert_eq!(
456            observed.literals.as_slice(),
457            &["\"is-enabled\"", "{", "\"last\"", "true", "}"]
458        );
459
460        let observed = {
461            let value = json!({"first": true, "last": true});
462            let o = path.final_error_value(&value);
463            assert_eq!(o.first_error_token(), Some("{"));
464            o
465        };
466        assert_eq!(
467            observed.literals.as_slice(),
468            &["\"is-enabled\"", "{", "\"last\"", "true", "}"]
469        );
470    }
471}
472
473#[cfg(feature = "client-lib")]
474#[cfg(test)]
475mod line_col_tests {
476
477    use super::*;
478    use crate::error::Result;
479
480    fn line_col<'a>(src: &'a str, path: impl Iterator<Item = &'a str>) -> (usize, usize) {
481        let mut lines = src.lines().peekable();
482        line_col_from_lines(&mut lines, (0, 0), path)
483    }
484
485    #[test]
486    fn test_find_err() -> Result<()> {
487        fn do_test(s: &str, path: &[&str], expected: (usize, usize)) {
488            let p = path.last().unwrap();
489            let path = path.iter().cloned();
490            let from = line_col(s, path);
491            assert_eq!(from, expected, "Can't find \"{p}\" at {expected:?} in {s}");
492        }
493
494        fn do_multi(s: &[&str], path: &[&str], expected: (usize, usize)) {
495            let s = s.join("\n");
496            do_test(&s, path, expected);
497        }
498
499        do_test("ab cd", &["cd"], (0, 3));
500        do_test("ab cd", &["ab", "cd"], (0, 3));
501        do_test("áط ¢đ εƒ gի", &["áط", "¢đ"], (0, 3));
502
503        do_test("ab ab", &["ab"], (0, 0));
504        do_test("ab ab", &["ab", "ab"], (0, 3));
505
506        do_multi(
507            &["ab xx cd", "xx ef xx gh", "ij xx"],
508            &["ab", "cd", "gh", "xx"],
509            (2, 3),
510        );
511
512        do_multi(
513            &[
514                "{",                       // 0
515                "  boolean: true,",        // 1
516                "  object: {",             // 2
517                "    integer: \"string\"", // 3
518                "  }",                     // 4
519                "}",                       // 5
520            ],
521            &["object", "integer", "\"string\""],
522            (3, 13),
523        );
524
525        // pathological case
526        do_multi(
527            &[
528                "{",                       // 0
529                "  boolean: true,",        // 1
530                "  object: {",             // 2
531                "    integer: 1,",         // 3
532                "    astring: \"string\"", // 4
533                "  },",                    // 5
534                "  integer: \"string\"",   // 6
535                "}",                       // 7
536            ],
537            &["integer", "\"string\""],
538            (4, 13),
539        );
540
541        // With unicode tokens (including R2L)
542        do_multi(&["áط ab", "¢đ cd", "εƒ ef", "gh gի"], &["áط", "cd"], (1, 3));
543
544        // Pseudolocalized pangrams, as a small fuzz test
545        do_multi(
546            &[
547                "Wàłţż, Waltz,",
548                "bâđ bad",
549                "ņÿmƥĥ, nymph,",
550                "ƒőŕ for",
551                "qüíĉķ quick",
552                "ĵíğş jigs",
553                "vęx vex",
554            ],
555            &["bad", "nymph"],
556            (2, 7),
557        );
558
559        Ok(())
560    }
561
562    #[test]
563    fn test_find_index_from() -> Result<()> {
564        assert_eq!(find_index("012345601", "01", 0), Some(0));
565        assert_eq!(find_index("012345601", "01", 1), Some(7));
566        assert_eq!(find_index("012345602", "01", 1), None);
567        assert_eq!(find_index("åéîø token", "token", 0), Some(5));
568        Ok(())
569    }
570}
571
572#[cfg(feature = "client-lib")]
573#[cfg(test)]
574mod integration_tests {
575
576    use serde_json::json;
577
578    use super::*;
579
580    fn test_error_span(src: &[&str], path: &ErrorPath, from: (usize, usize), to: (usize, usize)) {
581        test_error_span_string(src.join("\n"), path, from, to);
582    }
583
584    fn test_error_span_oneline(
585        src: &[&str],
586        path: &ErrorPath,
587        from: (usize, usize),
588        to: (usize, usize),
589    ) {
590        test_error_span_string(src.join(""), path, from, to);
591    }
592
593    fn test_error_span_string(
594        src: String,
595        path: &ErrorPath,
596        from: (usize, usize),
597        to: (usize, usize),
598    ) {
599        let observed = path.error_span(src.as_str());
600
601        assert_eq!(
602            observed.from,
603            from.into(),
604            "Incorrectly found first error token \"{p}\" starts at {from:?} in {src}",
605            from = observed.from,
606            p = path.first_error_token().unwrap()
607        );
608        assert_eq!(
609            observed.to,
610            to.into(),
611            "Incorrectly found last error token \"{p}\" ends at {to:?} in {src}",
612            p = path.last_error_token().unwrap(),
613            to = observed.to,
614        );
615    }
616
617    #[test]
618    fn test_last_token() {
619        let path = ErrorPath::feature("test-feature")
620            .property("integer")
621            .final_error_quoted("string");
622        let src = &[
623            // 01234567890123456789012345
624            r#"{"#,                     // 0
625            r#"  "boolean": true,"#,    // 1
626            r#"  "integer": "string""#, // 2
627            r#"}"#,                     // 3
628        ];
629
630        test_error_span(src, &path, (2, 13), (2, 21));
631        test_error_span_oneline(src, &path, (0, 32), (0, 32 + "string".len() + 2))
632    }
633
634    #[test]
635    fn test_type_mismatch_scalar() {
636        let path = ErrorPath::feature("test-feature")
637            .property("boolean")
638            .final_error_value(&json!(13));
639
640        let src = &[
641            // 01234567890123456789012345
642            r#"{"#,                // 0
643            r#"  "boolean": 13,"#, // 1
644            r#"  "integer": 1"#,   // 2
645            r#"}"#,                // 3
646        ];
647        test_error_span(src, &path, (1, 13), (1, 13 + 2));
648    }
649
650    #[test]
651    fn test_type_mismatch_error_on_one_line() {
652        let path = ErrorPath::feature("test-feature")
653            .property("integer")
654            .final_error_value(&json!({
655                "string": "string"
656            }));
657
658        let src = &[
659            // 01234567890123456789012345
660            r#"{"#,                                    // 0
661            r#"  "integer": { "string": "string" },"#, // 1
662            r#"  "short": 1,"#,                        // 2
663            r#"  "boolean": true,"#,                   // 3
664            r#"}"#,                                    // 4
665        ];
666        test_error_span(
667            src,
668            &path,
669            (1, 13),
670            (1, 13 + r#"{ "string": "string" }"#.len()),
671        );
672
673        test_error_span_oneline(
674            src,
675            &path,
676            (0, 14),
677            (0, 14 + r#"{ "string": "string" }"#.len()),
678        );
679    }
680
681    #[test]
682    fn test_type_mismatch_error_on_multiple_lines() {
683        let path = ErrorPath::feature("test-feature").final_error_value(&json!({}));
684        let src = &[
685            // 012345678
686            r#"{ "#, // 0
687            r#"  "#, // 1
688            r#"  "#, // 2
689            r#"  "#, // 3
690            r#"} "#, // 4
691        ];
692        test_error_span(src, &path, (0, 0), (4, 1));
693    }
694
695    #[test]
696    fn test_error_abbr() {
697        let path = ErrorPath::feature("test_feature").final_error_value(&json!(true));
698        assert_eq!(path.error_token_abbr().as_str(), "true");
699
700        let path = ErrorPath::feature("test_feature").final_error_value(&json!(42));
701        assert_eq!(path.error_token_abbr().as_str(), "42");
702
703        let path = ErrorPath::feature("test_feature").final_error_value(&json!("string"));
704        assert_eq!(path.error_token_abbr().as_str(), "\"string\"");
705
706        let path = ErrorPath::feature("test_feature").final_error_value(&json!([]));
707        assert_eq!(path.error_token_abbr().as_str(), "[…]");
708
709        let path = ErrorPath::feature("test_feature").final_error_value(&json!({}));
710        assert_eq!(path.error_token_abbr().as_str(), "{…}");
711
712        let path = ErrorPath::feature("test_feature").final_error_quoted("foo");
713        assert_eq!(path.error_token_abbr().as_str(), "\"foo\"");
714    }
715}