LCOV - code coverage report
Current view: top level - dashql-core/src - vis_text.rs Coverage Total Hit
Test: DashQL 6e74948 Lines: 98.6 % 216 213
Test Date: 2026-11-06 17:46:50

            Line data    Source code
       1              : //! Reading the content of `@vis` tags: the joined text of a tag and a tolerant JSON scanner.
       2              : //!
       3              : //! The content lines of a tag are joined with line breaks into one text, offsets map back to the script.
       4              : //! The JSON of `vegalite` tags is scanned token by token, also when it is incomplete or malformed, and
       5              : //! every scalar knows its slot: a key, the value of a key or an element of an array. Highlighting,
       6              : //! completion and the warnings of the analyzer find their way through the JSON with this scanner,
       7              : //! `serde_json` builds the values.
       8              : 
       9              : use dashql_parser::span::TextSpan;
      10              : 
      11              : /// The content of a tag: its lines joined with line breaks, mapped back to the script text
      12              : pub(crate) struct TagText {
      13              :     /// The joined content lines
      14              :     pub text: String,
      15              :     /// The script offset of every content line and its offset in the joined text
      16              :     lines: Vec<(usize, usize)>,
      17              :     /// The whole tag block, the location of spans in a tag without content
      18              :     block: TextSpan,
      19              : }
      20              : 
      21              : impl TagText {
      22          387 :     pub fn new(script: &str, lines: &[TextSpan], block: TextSpan) -> Self {
      23          387 :         let mut text = String::new();
      24          387 :         let mut offsets = Vec::with_capacity(lines.len());
      25          601 :         for (i, line) in lines.iter().enumerate() {
      26          601 :             if i > 0 {
      27          216 :                 text.push('\n');
      28          526 :             }
      29          601 :             offsets.push((line.offset as usize, text.len()));
      30          601 :             text.push_str(&script[line.offset as usize..line.end() as usize]);
      31              :         }
      32          387 :         Self {
      33          387 :             text,
      34          387 :             lines: offsets,
      35          387 :             block,
      36          387 :         }
      37          387 :     }
      38              : 
      39              :     /// Does the tag have content lines?
      40           21 :     pub fn is_empty(&self) -> bool {
      41           21 :         self.lines.is_empty()
      42           21 :     }
      43              : 
      44              :     /// The script span of a range of the joined text. A range across lines includes the comment markers
      45              :     /// and gutters between them.
      46          517 :     pub fn span(&self, begin: usize, end: usize) -> TextSpan {
      47          517 :         if self.lines.is_empty() {
      48            0 :             return self.block;
      49          517 :         }
      50          517 :         let (begin, end) = (self.to_script(begin), self.to_script(end));
      51          517 :         TextSpan::new(begin as u32, (end - begin) as u32)
      52          517 :     }
      53              : 
      54              :     /// The joined offset of a script offset. An offset before the content of a line, e.g. in its
      55              :     /// indentation, maps to the begin of the line.
      56           23 :     pub fn to_joined(&self, offset: usize) -> usize {
      57           23 :         let mut joined = 0;
      58           27 :         for (i, &(script, at)) in self.lines.iter().enumerate() {
      59           27 :             let len = self.line_len(i);
      60           27 :             if offset < script {
      61            1 :                 return at;
      62           26 :             }
      63           26 :             if offset <= script + len {
      64           22 :                 return at + offset - script;
      65            4 :             }
      66            4 :             joined = at + len;
      67              :         }
      68            0 :         joined
      69           23 :     }
      70              : 
      71              :     /// The script offset of a joined offset, the tag must have content lines
      72         1034 :     fn to_script(&self, joined: usize) -> usize {
      73         1312 :         let line = self.lines.partition_point(|&(_, at)| at <= joined).saturating_sub(1);
      74         1034 :         let (script, at) = self.lines[line];
      75         1034 :         script + joined - at
      76         1034 :     }
      77              : 
      78           27 :     fn line_len(&self, i: usize) -> usize {
      79           27 :         let end = self.lines.get(i + 1).map_or(self.text.len(), |&(_, at)| at - 1);
      80           27 :         end - self.lines[i].1
      81           27 :     }
      82              : }
      83              : 
      84              : /// The kind of a JSON token
      85              : #[derive(Clone, Copy, Debug, PartialEq, Eq)]
      86              : pub(crate) enum JsonTokenKind {
      87              :     ObjectBegin,
      88              :     ObjectEnd,
      89              :     ArrayBegin,
      90              :     ArrayEnd,
      91              :     Colon,
      92              :     Comma,
      93              :     /// A string, not closed if the text or the line ends before the closing quote
      94              :     String { closed: bool },
      95              :     /// A number, e.g. `-1.5e3`
      96              :     Number,
      97              :     /// `true`, `false` or `null`
      98              :     Literal,
      99              :     /// Any other text up to the next delimiter, e.g. a word that is still typed
     100              :     Word,
     101              : }
     102              : 
     103              : /// The slot of a scalar in its container
     104              : #[derive(Clone, Debug, PartialEq, Eq)]
     105              : pub(crate) enum JsonSlot {
     106              :     /// A key of an object
     107              :     Key,
     108              :     /// The value of a key
     109              :     Value(String),
     110              :     /// An element of an array
     111              :     Element,
     112              : }
     113              : 
     114              : /// An element of the path to a token
     115              : #[derive(Clone, Debug, PartialEq, Eq)]
     116              : pub(crate) enum PathItem {
     117              :     Key(String),
     118              :     Index,
     119              : }
     120              : 
     121              : /// A token of JSON text
     122              : #[derive(Clone, Debug, PartialEq, Eq)]
     123              : pub(crate) struct JsonToken {
     124              :     pub kind: JsonTokenKind,
     125              :     /// The byte range of the token
     126              :     pub begin: usize,
     127              :     pub end: usize,
     128              :     /// The number of containers around the token, the brackets of a container are outside of it
     129              :     pub depth: usize,
     130              :     /// The slot of a scalar (string, number, literal or word), None for brackets and punctuation and
     131              :     /// for a scalar where no value fits, e.g. a second value after a key
     132              :     pub slot: Option<JsonSlot>,
     133              : }
     134              : 
     135              : impl JsonToken {
     136              :     /// The value of a closed string
     137          139 :     pub fn string(&self, text: &[u8]) -> Option<String> {
     138          139 :         match self.kind {
     139          131 :             JsonTokenKind::String { closed: true } => serde_json::from_slice(&text[self.begin..self.end]).ok(),
     140            8 :             _ => None,
     141              :         }
     142          139 :     }
     143              : }
     144              : 
     145              : /// The state of an open container
     146              : enum Frame {
     147              :     Object { key: Option<String>, state: ObjectState },
     148              :     Array { expects_value: bool },
     149              : }
     150              : 
     151              : #[derive(Clone, Copy, PartialEq, Eq)]
     152              : enum ObjectState {
     153              :     ExpectKey,
     154              :     ExpectColon,
     155              :     ExpectValue,
     156              :     AfterValue,
     157              : }
     158              : 
     159              : /// The bytes that end a number, a literal or a word
     160              : const DELIMITERS: &[u8] = b"{}[],:\" \t\r\n";
     161              : 
     162              : /// Scans JSON text into tokens and tracks the containers. Nothing is an error: unbalanced brackets,
     163              : /// missing commas and unclosed strings are scanned as they come.
     164              : pub(crate) struct JsonScanner<'a> {
     165              :     text: &'a [u8],
     166              :     at: usize,
     167              :     stack: Vec<Frame>,
     168              : }
     169              : 
     170              : impl<'a> JsonScanner<'a> {
     171           43 :     pub fn new(text: &'a [u8]) -> Self {
     172           43 :         Self {
     173           43 :             text,
     174           43 :             at: 0,
     175           43 :             stack: Vec::new(),
     176           43 :         }
     177           43 :     }
     178              : 
     179              :     /// The slot of the next scalar, None outside of containers or where no value fits
     180          197 :     pub fn slot(&self) -> Option<JsonSlot> {
     181          197 :         match self.stack.last()? {
     182          131 :             Frame::Object { state: ObjectState::ExpectKey, .. } => Some(JsonSlot::Key),
     183           55 :             Frame::Object { state: ObjectState::ExpectValue, key } => Some(JsonSlot::Value(key.clone().unwrap_or_default())),
     184            7 :             Frame::Array { expects_value: true } => Some(JsonSlot::Element),
     185            3 :             _ => None,
     186              :         }
     187          197 :     }
     188              : 
     189              :     /// The path to the innermost open container
     190           27 :     pub fn path(&self) -> Vec<PathItem> {
     191           27 :         let outer = self.stack.split_last().map_or(&[][..], |(_, outer)| outer);
     192           27 :         outer
     193           27 :             .iter()
     194           44 :             .map(|frame| match frame {
     195           39 :                 Frame::Object { key, .. } => PathItem::Key(key.clone().unwrap_or_default()),
     196            5 :                 Frame::Array { .. } => PathItem::Index,
     197           44 :             })
     198           27 :             .collect()
     199           27 :     }
     200              : 
     201              :     /// A value ended, the container moves on
     202           81 :     fn value_done(&mut self) {
     203           81 :         match self.stack.last_mut() {
     204           65 :             Some(Frame::Object { state, .. }) => *state = ObjectState::AfterValue,
     205           10 :             Some(Frame::Array { expects_value }) => *expects_value = false,
     206            6 :             None => {}
     207              :         }
     208           81 :     }
     209              : 
     210              :     /// A scalar ended: a key or a value
     211          190 :     fn scalar_done(&mut self, token: &JsonToken) {
     212          190 :         let text = self.text;
     213          190 :         match self.stack.last_mut() {
     214          183 :             Some(Frame::Object { key, state }) if *state == ObjectState::ExpectKey => {
     215          128 :                 *key = Some(match token.kind {
     216          126 :                     JsonTokenKind::String { .. } => token.string(text).unwrap_or_default(),
     217            2 :                     _ => String::from_utf8_lossy(&text[token.begin..token.end]).into_owned(),
     218              :                 });
     219          128 :                 *state = ObjectState::ExpectColon;
     220              :             }
     221           62 :             _ => self.value_done(),
     222              :         }
     223          190 :     }
     224              : }
     225              : 
     226              : impl Iterator for JsonScanner<'_> {
     227              :     type Item = JsonToken;
     228              : 
     229          484 :     fn next(&mut self) -> Option<JsonToken> {
     230          484 :         let text = self.text;
     231          692 :         while self.at < text.len() && text[self.at].is_ascii_whitespace() {
     232          208 :             self.at += 1;
     233          208 :         }
     234          484 :         let begin = self.at;
     235          484 :         let c = *text.get(begin)?;
     236          470 :         let token = |kind, end, depth, slot| JsonToken { kind, begin, end, depth, slot };
     237          470 :         let depth = self.stack.len();
     238          470 :         match c {
     239              :             b'{' | b'[' => {
     240          108 :                 self.at += 1;
     241          108 :                 let (kind, frame) = if c == b'{' {
     242           96 :                     (JsonTokenKind::ObjectBegin, Frame::Object { key: None, state: ObjectState::ExpectKey })
     243              :                 } else {
     244           12 :                     (JsonTokenKind::ArrayBegin, Frame::Array { expects_value: true })
     245              :                 };
     246          108 :                 self.stack.push(frame);
     247          108 :                 Some(token(kind, self.at, depth, None))
     248              :             }
     249              :             b'}' | b']' => {
     250           19 :                 self.at += 1;
     251           19 :                 self.stack.pop();
     252           19 :                 self.value_done();
     253           19 :                 let kind = if c == b'}' { JsonTokenKind::ObjectEnd } else { JsonTokenKind::ArrayEnd };
     254           19 :                 Some(token(kind, self.at, self.stack.len(), None))
     255              :             }
     256              :             b',' => {
     257           39 :                 self.at += 1;
     258           39 :                 match self.stack.last_mut() {
     259           35 :                     Some(Frame::Object { key, state }) => {
     260           35 :                         *key = None;
     261           35 :                         *state = ObjectState::ExpectKey;
     262           35 :                     }
     263            4 :                     Some(Frame::Array { expects_value }) => *expects_value = true,
     264            0 :                     None => {}
     265              :                 }
     266           39 :                 Some(token(JsonTokenKind::Comma, self.at, depth, None))
     267              :             }
     268              :             b':' => {
     269          114 :                 self.at += 1;
     270          114 :                 if let Some(Frame::Object { state, .. }) = self.stack.last_mut() {
     271          114 :                     *state = ObjectState::ExpectValue;
     272          114 :                 }
     273          114 :                 Some(token(JsonTokenKind::Colon, self.at, depth, None))
     274              :             }
     275              :             b'"' => {
     276              :                 // JSON strings contain no line breaks, an unclosed string ends with its line
     277          182 :                 let mut i = begin + 1;
     278          182 :                 let mut closed = false;
     279          906 :                 while i < text.len() && text[i] != b'\n' {
     280          887 :                     match text[i] {
     281              :                         b'"' => {
     282          163 :                             closed = true;
     283          163 :                             i += 1;
     284          163 :                             break;
     285              :                         }
     286            1 :                         b'\\' if text.get(i + 1).is_some_and(|&c| c != b'\n') => i += 2,
     287          723 :                         _ => i += 1,
     288              :                     }
     289              :                 }
     290          182 :                 self.at = i;
     291          182 :                 let string = token(JsonTokenKind::String { closed }, i, depth, self.slot());
     292          182 :                 self.scalar_done(&string);
     293          182 :                 Some(string)
     294              :             }
     295              :             _ => {
     296            8 :                 let mut i = begin;
     297           34 :                 while i < text.len() && !DELIMITERS.contains(&text[i]) {
     298           26 :                     i += 1;
     299           26 :                 }
     300            8 :                 self.at = i;
     301            8 :                 let word = &text[begin..i];
     302            8 :                 let kind = if matches!(word, b"true" | b"false" | b"null") {
     303            3 :                     JsonTokenKind::Literal
     304            5 :                 } else if (c == b'-' || c.is_ascii_digit())
     305            9 :                     && word.iter().all(|&c| c.is_ascii_digit() || matches!(c, b'.' | b'e' | b'E' | b'+' | b'-'))
     306              :                 {
     307            2 :                     JsonTokenKind::Number
     308              :                 } else {
     309            3 :                     JsonTokenKind::Word
     310              :                 };
     311            8 :                 let scalar = token(kind, i, depth, self.slot());
     312            8 :                 self.scalar_done(&scalar);
     313            8 :                 Some(scalar)
     314              :             }
     315              :         }
     316          484 :     }
     317              : }
     318              : 
     319              : #[cfg(test)]
     320              : mod tests {
     321              :     use super::*;
     322              : 
     323            3 :     fn tokens(json: &str) -> Vec<(&str, JsonTokenKind, usize, Option<JsonSlot>)> {
     324           26 :         JsonScanner::new(json.as_bytes()).map(|t| (&json[t.begin..t.end], t.kind, t.depth, t.slot)).collect()
     325            3 :     }
     326              : 
     327              :     #[test]
     328            1 :     fn scans_tokens_with_their_slots() {
     329              :         use JsonSlot::*;
     330              :         use JsonTokenKind::*;
     331            2 :         let value = |key: &str| Some(Value(key.to_string()));
     332            1 :         assert_eq!(
     333            1 :             tokens("{\"mark\": \"bar\", \"x\": [1.5, true], \"y\": null}"),
     334            1 :             vec![
     335            1 :                 ("{", ObjectBegin, 0, None),
     336            1 :                 ("\"mark\"", String { closed: true }, 1, Some(Key)),
     337            1 :                 (":", Colon, 1, None),
     338            1 :                 ("\"bar\"", String { closed: true }, 1, value("mark")),
     339            1 :                 (",", Comma, 1, None),
     340            1 :                 ("\"x\"", String { closed: true }, 1, Some(Key)),
     341            1 :                 (":", Colon, 1, None),
     342            1 :                 ("[", ArrayBegin, 1, None),
     343            1 :                 ("1.5", Number, 2, Some(Element)),
     344            1 :                 (",", Comma, 2, None),
     345            1 :                 ("true", Literal, 2, Some(Element)),
     346            1 :                 ("]", ArrayEnd, 1, None),
     347            1 :                 (",", Comma, 1, None),
     348            1 :                 ("\"y\"", String { closed: true }, 1, Some(Key)),
     349            1 :                 (":", Colon, 1, None),
     350            1 :                 ("null", Literal, 1, value("y")),
     351            1 :                 ("}", ObjectEnd, 0, None),
     352              :             ]
     353              :         );
     354            1 :     }
     355              : 
     356              :     #[test]
     357            1 :     fn scans_malformed_json() {
     358              :         use JsonSlot::*;
     359              :         use JsonTokenKind::*;
     360              :         // An unclosed string ends with its line, an escaped quote does not close a string. Without a
     361              :         // comma, the next value still belongs to the key before.
     362            1 :         assert_eq!(
     363            1 :             tokens("{\"a\": \"b\n\"c\\\"\": 12px"),
     364            1 :             vec![
     365            1 :                 ("{", ObjectBegin, 0, None),
     366            1 :                 ("\"a\"", String { closed: true }, 1, Some(Key)),
     367            1 :                 (":", Colon, 1, None),
     368            1 :                 ("\"b", String { closed: false }, 1, Some(Value("a".into()))),
     369            1 :                 ("\"c\\\"\"", String { closed: true }, 1, None),
     370            1 :                 (":", Colon, 1, None),
     371            1 :                 ("12px", Word, 1, Some(Value("a".into()))),
     372              :             ]
     373              :         );
     374              :         // Unbalanced brackets
     375            1 :         assert_eq!(tokens("}]"), vec![("}", ObjectEnd, 0, None), ("]", ArrayEnd, 0, None)]);
     376            1 :     }
     377              : 
     378              :     #[test]
     379            1 :     fn tracks_the_path() {
     380            1 :         let mut scanner = JsonScanner::new(b"{\"layer\": [{\"encoding\": {\"x\": {");
     381            1 :         scanner.by_ref().for_each(drop);
     382            3 :         let key = |k: &str| PathItem::Key(k.to_string());
     383            1 :         assert_eq!(scanner.path(), vec![key("layer"), PathItem::Index, key("encoding"), key("x")]);
     384            1 :         assert_eq!(scanner.slot(), Some(JsonSlot::Key));
     385            1 :     }
     386              : 
     387              :     #[test]
     388            1 :     fn maps_the_tag_text_to_the_script() {
     389            1 :         let script = "-- @vis vegalite {\n--   \"mark\": 1\n-- }";
     390            3 :         let line = |text: &str| TextSpan::new(script.find(text).unwrap() as u32, text.len() as u32);
     391            1 :         let tag = TagText::new(script, &[line("vegalite {"), line("\"mark\": 1"), line("}")], TextSpan::new(0, 0));
     392            1 :         assert_eq!(tag.text, "vegalite {\n\"mark\": 1\n}");
     393            2 :         let span = |begin: usize, end: usize| {
     394            2 :             let span = tag.span(begin, end);
     395            2 :             &script[span.offset as usize..span.end() as usize]
     396            2 :         };
     397            1 :         assert_eq!(span(11, 17), "\"mark\"");
     398            1 :         assert_eq!(span(9, tag.text.len()), "{\n--   \"mark\": 1\n-- }");
     399            1 :         assert_eq!(tag.to_joined(script.find("\"mark\"").unwrap()), 11);
     400            1 :         assert_eq!(tag.to_joined(script.find("--   ").unwrap()), 11);
     401            1 :     }
     402              : }
        

Generated by: LCOV version 2.0-1