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

            Line data    Source code
       1              : //! The syntax spans of comments: comment tags are highlighted like code, everything else is a comment.
       2              : //!
       3              : //! Every tag, `@vis`, `@board`, `@flow`, `@param` and the `@end` that older scripts close a region
       4              : //! of line comments with, is a keyword, so the tags of an annotation read alike. The JSON of `@vis vegalite` is split
       5              : //! into its tokens: keys are identifiers, strings, numbers and booleans are literals. In a `@vis` tag, the kind is a
       6              : //! keyword and `{{field}}` placeholders of Markdown templates are identifiers. The keys of the args of `@board` and
       7              : //! `@flow` (and of `@vis board` and `@vis flow` of older scripts) are identifiers. The rest of the comment stays a
       8              : //! comment, e.g. the value of a `@param`.
       9              : //!
      10              : //! The JSON objects of `vegalite` tags are found with the tokens, editors fold them.
      11              : 
      12              : use crate::vis_text::{JsonScanner, JsonSlot, JsonTokenKind, TagText};
      13              : use dashql_buffers::parser::{CommentBlockType, ScannerTokenType};
      14              : use dashql_parser::comment_blocks::{COMMENT_TAG_END, COMMENT_TAG_PREFIX};
      15              : use dashql_parser::parser::ParsedScript;
      16              : use dashql_parser::span::TextSpan;
      17              : 
      18              : /// The tag of visualizations
      19              : const VIS_TAG: &[u8] = b"vis";
      20              : /// The tag of the dashboard placement
      21              : const BOARD_TAG: &[u8] = b"board";
      22              : /// The tag of the position on the dataflow canvas
      23              : const FLOW_TAG: &[u8] = b"flow";
      24              : 
      25              : /// The tokens of the content lines of a `board` or `markdown` tag
      26              : struct Lines<'a> {
      27              :     script: &'a [u8],
      28              :     out: &'a mut Vec<(TextSpan, ScannerTokenType)>,
      29              : }
      30              : 
      31              : impl Lines<'_> {
      32           61 :     fn push(&mut self, begin: usize, end: usize, token: ScannerTokenType) {
      33           61 :         if end > begin {
      34           61 :             self.out.push((TextSpan::new(begin as u32, (end - begin) as u32), token));
      35           61 :         }
      36           61 :     }
      37              : 
      38              :     /// The `key=value` args of a board placement
      39            7 :     fn args(&mut self, begin: usize, end: usize) {
      40            7 :         let text = self.script;
      41            7 :         let mut i = begin;
      42           20 :         while i < end {
      43           20 :             while i < end && text[i].is_ascii_whitespace() {
      44            7 :                 i += 1;
      45            7 :             }
      46           13 :             let arg_begin = i;
      47          106 :             while i < end && !text[i].is_ascii_whitespace() {
      48           93 :                 i += 1;
      49           93 :             }
      50           71 :             if let Some(eq) = text[arg_begin..i].iter().position(|&c| c == b'=') {
      51           13 :                 self.push(arg_begin, arg_begin + eq, ScannerTokenType::IDENTIFIER);
      52           13 :                 self.push(arg_begin + eq + 1, i, ScannerTokenType::LITERAL_INTEGER);
      53           13 :             }
      54              :         }
      55            7 :     }
      56              : 
      57              :     /// The `{{field}}` placeholders of a Markdown template
      58            2 :     fn placeholders(&mut self, begin: usize, end: usize) {
      59            2 :         let text = self.script;
      60            2 :         let mut i = begin;
      61            9 :         while i + 1 < end {
      62            7 :             if &text[i..i + 2] != b"{{" {
      63            5 :                 i += 1;
      64            5 :                 continue;
      65            2 :             }
      66            4 :             match text[i + 2..end].windows(2).position(|w| w == b"}}") {
      67            2 :                 Some(close) => {
      68            2 :                     let close = i + 2 + close + 2;
      69            2 :                     self.push(i, close, ScannerTokenType::IDENTIFIER);
      70            2 :                     i = close;
      71            2 :                 }
      72            0 :                 None => break,
      73              :             }
      74              :         }
      75            2 :     }
      76              : }
      77              : 
      78              : /// The tokens of the JSON of a tag, from `body` in the tag text, and the spans of its outermost objects
      79            4 : fn json_tokens(tag: &TagText, body: usize, out: &mut Vec<(TextSpan, ScannerTokenType)>, mut objects: Option<&mut Vec<TextSpan>>) {
      80            4 :     let json = &tag.text.as_bytes()[body..];
      81            4 :     let mut object_begin = None;
      82           35 :     for token in JsonScanner::new(json) {
      83           35 :         let (begin, end) = (body + token.begin, body + token.end);
      84           15 :         let token_type = match token.kind {
      85            5 :             JsonTokenKind::ObjectBegin if token.depth == 0 => {
      86            4 :                 object_begin = Some(begin);
      87            4 :                 continue;
      88              :             }
      89            4 :             JsonTokenKind::ObjectEnd if token.depth == 0 => {
      90            3 :                 if let (Some(begin), Some(objects)) = (object_begin.take(), objects.as_deref_mut()) {
      91            3 :                     objects.push(tag.span(begin, end));
      92            3 :                 }
      93            3 :                 continue;
      94              :             }
      95           13 :             JsonTokenKind::String { .. } if token.slot == Some(JsonSlot::Key) => ScannerTokenType::IDENTIFIER,
      96            5 :             JsonTokenKind::String { .. } => ScannerTokenType::LITERAL_STRING,
      97            2 :             JsonTokenKind::Number if json[token.begin..token.end].iter().any(|c| matches!(c, b'.' | b'e' | b'E')) => {
      98            1 :                 ScannerTokenType::LITERAL_FLOAT
      99              :             }
     100            0 :             JsonTokenKind::Number => ScannerTokenType::LITERAL_INTEGER,
     101            1 :             JsonTokenKind::Literal if &json[token.begin..token.end] == b"null" => ScannerTokenType::KEYWORD,
     102            1 :             JsonTokenKind::Literal => ScannerTokenType::LITERAL_BOOLEAN,
     103           13 :             _ => continue,
     104              :         };
     105           15 :         out.push((tag.span(begin, end), token_type));
     106              :     }
     107            4 : }
     108              : 
     109              : /// The `@end` of a line comment that closes a tagged region, as script offsets
     110           20 : fn end_tag(script: &[u8], comment: TextSpan) -> Option<(usize, usize)> {
     111           20 :     let (begin, end) = (comment.offset as usize, comment.end() as usize);
     112           20 :     let text = &script[begin..end];
     113           34 :     let dashes = text.iter().take_while(|&&c| c == b'-').count();
     114           20 :     if dashes < 2 {
     115           13 :         return None;
     116            7 :     }
     117            7 :     let trimmed = text[dashes..].trim_ascii();
     118            7 :     let end_tag = [COMMENT_TAG_PREFIX.as_bytes(), COMMENT_TAG_END.as_bytes()].concat();
     119            7 :     if trimmed != end_tag.as_slice() {
     120            3 :         return None;
     121            4 :     }
     122            4 :     let at = begin + dashes + (text[dashes..].len() - text[dashes..].trim_ascii_start().len());
     123            4 :     Some((at, at + end_tag.len()))
     124           20 : }
     125              : 
     126              : /// The highlighted tokens of the comment tags, sorted by offset, and the JSON objects of the `vegalite` tags
     127          106 : fn tag_tokens(parsed: &ParsedScript) -> (Vec<(TextSpan, ScannerTokenType)>, Vec<TextSpan>) {
     128          106 :     let script = parsed.scanned.text.as_bytes();
     129          106 :     let blocks = &parsed.comment_blocks;
     130          106 :     let mut out = Vec::new();
     131          106 :     let mut objects = Vec::new();
     132          109 :     for block in &blocks.blocks {
     133           32 :         if block.block_type != CommentBlockType::DASHQL_TAG {
     134           12 :             continue;
     135           20 :         }
     136           20 :         let tag = block.tag_span;
     137           20 :         let name = &script[tag.offset as usize..tag.end() as usize];
     138           20 :         let mut lines = Lines { script, out: &mut out };
     139              :         // The tag with its `@`, and the end line of a region of line comments
     140           20 :         lines.push(tag.offset as usize - 1, tag.end() as usize, ScannerTokenType::KEYWORD);
     141           20 :         let last = parsed.scanned.comments[(block.comments_begin + block.comments_count - 1) as usize];
     142           20 :         if let Some((begin, end)) = end_tag(script, last) {
     143            4 :             lines.push(begin, end, ScannerTokenType::KEYWORD);
     144           16 :         }
     145           20 :         let content = &blocks.lines[block.lines_begin as usize..(block.lines_begin + block.lines_count) as usize];
     146              :         // `@board` and `@flow` are args
     147           20 :         if name == BOARD_TAG || name == FLOW_TAG {
     148            6 :             if let Some(first) = content.first() {
     149            6 :                 lines.args(first.offset as usize, first.end() as usize);
     150            6 :             }
     151            6 :             continue;
     152           14 :         }
     153           14 :         if name != VIS_TAG {
     154            5 :             continue;
     155            9 :         }
     156            9 :         let Some(first) = content.first() else { continue };
     157            9 :         let first_begin = first.offset as usize;
     158            9 :         let mut kind_end = first_begin;
     159           72 :         while kind_end < first.end() as usize && !script[kind_end].is_ascii_whitespace() {
     160           63 :             kind_end += 1;
     161           63 :         }
     162            9 :         lines.push(first_begin, kind_end, ScannerTokenType::KEYWORD);
     163            9 :         let kind = &script[first_begin..kind_end];
     164            9 :         if kind == b"vegalite" {
     165            4 :             let text = TagText::new(&parsed.scanned.text, content, block.text_span);
     166            4 :             json_tokens(&text, kind_end - first_begin, &mut out, Some(&mut objects));
     167            4 :             continue;
     168            5 :         }
     169            5 :         for (i, line) in content.iter().enumerate() {
     170            5 :             let begin = if i == 0 { kind_end } else { line.offset as usize };
     171            5 :             let end = line.end() as usize;
     172            0 :             match kind {
     173            5 :                 b"board" | b"card" | b"flow" if i == 0 => lines.args(begin, end),
     174            2 :                 b"markdown" => lines.placeholders(begin, end),
     175            2 :                 _ => {}
     176              :             }
     177              :         }
     178              :     }
     179          106 :     out.sort_by_key(|(span, _)| span.offset);
     180          106 :     objects.sort_by_key(|span| span.offset);
     181          106 :     (out, objects)
     182          106 : }
     183              : 
     184              : /// The syntax spans of the comments: a comment is one comment span, comment tags are split into their
     185              : /// tokens and the comment spans between them. Returns the spans and the JSON objects of the `vegalite` tags.
     186          106 : pub fn comment_syntax_spans(parsed: &ParsedScript) -> (Vec<(TextSpan, ScannerTokenType)>, Vec<TextSpan>) {
     187          106 :     let (tokens, objects) = tag_tokens(parsed);
     188          106 :     let mut out = Vec::with_capacity(parsed.scanned.comments.len() + tokens.len() * 2);
     189          106 :     let mut next = 0;
     190          112 :     for &comment in &parsed.scanned.comments {
     191           37 :         let mut at = comment.offset as usize;
     192           37 :         let end = comment.end() as usize;
     193           37 :         while next < tokens.len() && (tokens[next].0.offset as usize) < at {
     194            0 :             next += 1;
     195            0 :         }
     196          113 :         while next < tokens.len() && (tokens[next].0.end() as usize) <= end {
     197           76 :             let (span, token) = tokens[next];
     198           76 :             if span.offset as usize > at {
     199           76 :                 out.push((TextSpan::new(at as u32, span.offset - at as u32), ScannerTokenType::COMMENT));
     200           76 :             }
     201           76 :             out.push((span, token));
     202           76 :             at = span.end() as usize;
     203           76 :             next += 1;
     204              :         }
     205           37 :         if end > at {
     206           30 :             out.push((TextSpan::new(at as u32, (end - at) as u32), ScannerTokenType::COMMENT));
     207           30 :         }
     208              :     }
     209          106 :     (out, objects)
     210          106 : }
     211              : 
     212              : #[cfg(test)]
     213              : mod tests {
     214              :     use super::*;
     215              :     use dashql_parser::parser::parse;
     216              :     use dashql_parser::scanner::scan;
     217              :     use std::sync::Arc;
     218              : 
     219            6 :     fn spans(text: &str) -> Vec<(&str, ScannerTokenType)> {
     220            6 :         let parsed = parse(Arc::new(scan(text, 0, 0)));
     221            6 :         comment_syntax_spans(&parsed)
     222            6 :             .0
     223            6 :             .into_iter()
     224           52 :             .map(|(span, token)| (&text[span.offset as usize..span.end() as usize], token))
     225            6 :             .collect()
     226            6 :     }
     227              : 
     228              :     #[test]
     229            1 :     fn highlights_vis_tags() {
     230              :         use ScannerTokenType as T;
     231            1 :         assert_eq!(spans("/* plain @vis */ select 1"), vec![("/* plain @vis */", T::COMMENT)]);
     232            1 :         assert_eq!(
     233            1 :             spans("/* @vis vegalite {\"mark\": \"bar\", \"x\": 1.5, \"y\": true} */ select 1"),
     234            1 :             vec![
     235            1 :                 ("/* ", T::COMMENT),
     236            1 :                 ("@vis", T::KEYWORD),
     237            1 :                 (" ", T::COMMENT),
     238            1 :                 ("vegalite", T::KEYWORD),
     239            1 :                 (" {", T::COMMENT),
     240            1 :                 ("\"mark\"", T::IDENTIFIER),
     241            1 :                 (": ", T::COMMENT),
     242            1 :                 ("\"bar\"", T::LITERAL_STRING),
     243            1 :                 (", ", T::COMMENT),
     244            1 :                 ("\"x\"", T::IDENTIFIER),
     245            1 :                 (": ", T::COMMENT),
     246            1 :                 ("1.5", T::LITERAL_FLOAT),
     247            1 :                 (", ", T::COMMENT),
     248            1 :                 ("\"y\"", T::IDENTIFIER),
     249            1 :                 (": ", T::COMMENT),
     250            1 :                 ("true", T::LITERAL_BOOLEAN),
     251            1 :                 ("} */", T::COMMENT),
     252              :             ]
     253              :         );
     254            1 :         assert_eq!(
     255            1 :             spans("-- @vis board width=6\n-- @vis markdown **{{a}}**\n-- @end\nselect 1"),
     256            1 :             vec![
     257            1 :                 ("-- ", T::COMMENT),
     258            1 :                 ("@vis", T::KEYWORD),
     259            1 :                 (" ", T::COMMENT),
     260            1 :                 ("board", T::KEYWORD),
     261            1 :                 (" ", T::COMMENT),
     262            1 :                 ("width", T::IDENTIFIER),
     263            1 :                 ("=", T::COMMENT),
     264            1 :                 ("6", T::LITERAL_INTEGER),
     265            1 :                 ("-- ", T::COMMENT),
     266            1 :                 ("@vis", T::KEYWORD),
     267            1 :                 (" ", T::COMMENT),
     268            1 :                 ("markdown", T::KEYWORD),
     269            1 :                 (" **", T::COMMENT),
     270            1 :                 ("{{a}}", T::IDENTIFIER),
     271            1 :                 ("**", T::COMMENT),
     272            1 :                 ("-- ", T::COMMENT),
     273            1 :                 ("@end", T::KEYWORD),
     274              :             ]
     275              :         );
     276            1 :     }
     277              : 
     278              :     #[test]
     279            1 :     fn highlights_every_tag_alike() {
     280              :         use ScannerTokenType as T;
     281              :         // The content of a tag without tokens stays a comment, the keys of the args of a board are identifiers
     282            1 :         assert_eq!(
     283            1 :             spans("/* @param Sales.\n   @board width=6\n*/\nselect 1"),
     284            1 :             vec![
     285            1 :                 ("/* ", T::COMMENT),
     286            1 :                 ("@param", T::KEYWORD),
     287            1 :                 (" Sales.\n   ", T::COMMENT),
     288            1 :                 ("@board", T::KEYWORD),
     289            1 :                 (" ", T::COMMENT),
     290            1 :                 ("width", T::IDENTIFIER),
     291            1 :                 ("=", T::COMMENT),
     292            1 :                 ("6", T::LITERAL_INTEGER),
     293            1 :                 ("\n*/", T::COMMENT),
     294              :             ]
     295              :         );
     296            1 :         assert_eq!(
     297            1 :             spans("-- @param\n-- Sales.\n--   @end\nselect 1"),
     298            1 :             vec![
     299            1 :                 ("-- ", T::COMMENT),
     300            1 :                 ("@param", T::KEYWORD),
     301            1 :                 ("-- Sales.", T::COMMENT),
     302            1 :                 ("--   ", T::COMMENT),
     303            1 :                 ("@end", T::KEYWORD),
     304              :             ]
     305              :         );
     306              :         // A region of line comments ends with the run, the end line is not needed
     307            1 :         assert_eq!(
     308            1 :             spans("-- @param Sales.\nselect 1"),
     309            1 :             vec![("-- ", T::COMMENT), ("@param", T::KEYWORD), (" Sales.", T::COMMENT)]
     310              :         );
     311            1 :     }
     312              : 
     313            4 :     fn vegalite_specs(text: &str) -> Vec<&str> {
     314            4 :         let parsed = parse(Arc::new(scan(text, 0, 0)));
     315            4 :         comment_syntax_spans(&parsed)
     316            4 :             .1
     317            4 :             .into_iter()
     318            4 :             .map(|span| &text[span.offset as usize..span.end() as usize])
     319            4 :             .collect()
     320            4 :     }
     321              : 
     322              :     #[test]
     323            1 :     fn finds_vegalite_specs() {
     324            1 :         assert_eq!(
     325            1 :             vegalite_specs("/* @vis vegalite\n   {\n     \"mark\": {\"type\": \"bar\"},\n     \"title\": \"a } b\"\n   }\n*/\nselect 1"),
     326            1 :             vec!["{\n     \"mark\": {\"type\": \"bar\"},\n     \"title\": \"a } b\"\n   }"]
     327              :         );
     328            1 :         assert_eq!(vegalite_specs("-- @vis vegalite {\"mark\":\"bar\"}\n-- @end\nselect 1"), vec!["{\"mark\":\"bar\"}"]);
     329              :         // Only the JSON of vegalite tags, and only closed objects
     330            1 :         assert_eq!(vegalite_specs("/* @vis markdown {{a}} */ select 1; /* {\"mark\": 1} */ select 2"), Vec::<&str>::new());
     331            1 :         assert_eq!(vegalite_specs("/* @vis vegalite {\"mark\": \"bar\" */ select 1"), Vec::<&str>::new());
     332            1 :     }
     333              : }
        

Generated by: LCOV version 2.0-1